Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/src/main/java/app/gamenative/service/DownloadService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ object DownloadService {
field = value
}

// all mounted non-primary external volumes (SD cards, USB), discovered at init
// All mounted install target volumes (SD cards, USB, and adopted primary storage), discovered at init
var externalVolumePaths: List<String> = emptyList()
private set

Expand All @@ -40,7 +40,7 @@ object DownloadService {
val sm = context.getSystemService(android.os.storage.StorageManager::class.java)
val appFilesDirs = StorageUtils.getAllExternalFilesDirs(context)
.filter { Environment.getExternalStorageState(it) == Environment.MEDIA_MOUNTED }
.filter { sm?.getStorageVolume(it)?.isPrimary != true }
.filter { StorageUtils.isExternalInstallTarget(sm, it) }
// both layouts per volume: legacy Android/data (existing installs) + public root (new installs)
externalVolumePaths = appFilesDirs
.flatMap { dir -> listOfNotNull(dir.absolutePath, StorageUtils.publicInstallRoot(dir)?.absolutePath) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -525,7 +525,7 @@ fun SettingsGroupInterface(
val ctx = LocalContext.current
val sm = ctx.getSystemService(StorageManager::class.java)

// All writable non-primary volumes (SD / USB).
// All writable install target volumes (SD / USB / adopted primary storage).
// getExternalFilesDirs misses USB OTG on most devices, so StorageUtils also
// enumerates StorageManager.storageVolumes and synthesizes the per-app files dir.
// Runs off the composition thread because synthesizing the USB candidate
Expand All @@ -535,7 +535,7 @@ fun SettingsGroupInterface(
value = withContext(Dispatchers.IO) {
StorageUtils.getAllExternalFilesDirs(ctx)
.filter { Environment.getExternalStorageState(it) == Environment.MEDIA_MOUNTED }
.filter { sm?.getStorageVolume(it)?.isPrimary != true }
.filter { StorageUtils.isExternalInstallTarget(sm, it) }
}
}

Expand Down Expand Up @@ -804,4 +804,3 @@ private fun Preview_SettingsScreen() {
)
}
}

43 changes: 43 additions & 0 deletions app/src/main/java/app/gamenative/utils/StorageUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,49 @@ object StorageUtils {
}
}

/**
* Returns whether [appFilesDir] should be offered as a separate install target.
*
* Primary storage is not always backed by built-in storage. When Android migrates primary
* shared storage to adopted media, the SD-backed volume remains primary but has a non-default
* storage UUID.
*/
fun isExternalInstallTarget(storageManager: StorageManager?, appFilesDir: File): Boolean {
val volume = storageManager?.getStorageVolume(appFilesDir)
?: return runCatching { Environment.isExternalStorageRemovable(appFilesDir) }.getOrDefault(false)
if (!volume.isPrimary) return true

val resolvedStorageUuid = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
volume.storageUuid
} else {
try {
storageManager.getUuidForPath(appFilesDir)
} catch (_: Exception) {
null
}
}
Comment on lines +157 to +170

return isNonDefaultPrimaryStorage(
resolvedStorageUuid = resolvedStorageUuid,
legacyVolumeUuid = volume.uuid,
isPhysicalPrimary = volume.isRemovable && !volume.isEmulated,
allowLegacyUuidFallback = Build.VERSION.SDK_INT < Build.VERSION_CODES.S,
defaultStorageUuid = StorageManager.UUID_DEFAULT,
)
}

internal fun isNonDefaultPrimaryStorage(
resolvedStorageUuid: java.util.UUID?,
legacyVolumeUuid: String?,
isPhysicalPrimary: Boolean,
allowLegacyUuidFallback: Boolean,
defaultStorageUuid: java.util.UUID,
): Boolean {
if (resolvedStorageUuid != null) return resolvedStorageUuid != defaultStorageUuid
if (isPhysicalPrimary) return true
return allowLegacyUuidFallback && !legacyVolumeUuid.isNullOrBlank()
}

/**
* Gets all app-specific external files directories, using StorageManager as a fallback
* for cases where context.getExternalFilesDirs(null) might return null or incomplete results
Expand Down
102 changes: 102 additions & 0 deletions app/src/test/java/app/gamenative/utils/StorageUtilsTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package app.gamenative.utils

import java.util.UUID
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

class StorageUtilsTest {

private val defaultStorageUuid = UUID.fromString("41217664-9172-527a-b3d5-edabb50a7d69")

@Test
fun `built-in primary storage is not a separate install target`() {
assertFalse(
StorageUtils.isNonDefaultPrimaryStorage(
resolvedStorageUuid = defaultStorageUuid,
legacyVolumeUuid = null,
isPhysicalPrimary = false,
allowLegacyUuidFallback = false,
defaultStorageUuid = defaultStorageUuid,
),
)
}

@Test
fun `default UUID overrides weaker physical and legacy signals`() {
assertFalse(
StorageUtils.isNonDefaultPrimaryStorage(
resolvedStorageUuid = defaultStorageUuid,
legacyVolumeUuid = "ABCD-1234",
isPhysicalPrimary = true,
allowLegacyUuidFallback = true,
defaultStorageUuid = defaultStorageUuid,
),
)
}

@Test
fun `adopted primary storage is an install target`() {
assertTrue(
StorageUtils.isNonDefaultPrimaryStorage(
resolvedStorageUuid = UUID.fromString("12345678-1234-1234-1234-123456789abc"),
legacyVolumeUuid = null,
isPhysicalPrimary = false,
allowLegacyUuidFallback = false,
defaultStorageUuid = defaultStorageUuid,
),
)
}

@Test
fun `legacy adopted primary falls back to its volume UUID`() {
assertTrue(
StorageUtils.isNonDefaultPrimaryStorage(
resolvedStorageUuid = null,
legacyVolumeUuid = "ABCD-1234",
isPhysicalPrimary = false,
allowLegacyUuidFallback = true,
defaultStorageUuid = defaultStorageUuid,
),
)
}

@Test
fun `modern primary does not use a raw UUID when its typed UUID is unavailable`() {
assertFalse(
StorageUtils.isNonDefaultPrimaryStorage(
resolvedStorageUuid = null,
legacyVolumeUuid = "ABCD-1234",
isPhysicalPrimary = false,
allowLegacyUuidFallback = false,
defaultStorageUuid = defaultStorageUuid,
),
)
}

@Test
fun `physical primary storage remains an install target when UUID lookup fails`() {
assertTrue(
StorageUtils.isNonDefaultPrimaryStorage(
resolvedStorageUuid = null,
legacyVolumeUuid = null,
isPhysicalPrimary = true,
allowLegacyUuidFallback = false,
defaultStorageUuid = defaultStorageUuid,
),
)
}

@Test
fun `unidentified emulated primary storage is not an install target`() {
assertFalse(
StorageUtils.isNonDefaultPrimaryStorage(
resolvedStorageUuid = null,
legacyVolumeUuid = null,
isPhysicalPrimary = false,
allowLegacyUuidFallback = true,
defaultStorageUuid = defaultStorageUuid,
),
)
}
}
Loading