From a94949d5ef46ad14ed30da607723a3be7fbe28cd Mon Sep 17 00:00:00 2001 From: danielbuva Date: Sat, 15 Aug 2026 11:20:07 -0700 Subject: [PATCH] fix: support adopted primary storage installs --- .../app/gamenative/service/DownloadService.kt | 4 +- .../screen/settings/SettingsGroupInterface.kt | 5 +- .../java/app/gamenative/utils/StorageUtils.kt | 43 ++++++++ .../app/gamenative/utils/StorageUtilsTest.kt | 102 ++++++++++++++++++ 4 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 app/src/test/java/app/gamenative/utils/StorageUtilsTest.kt diff --git a/app/src/main/java/app/gamenative/service/DownloadService.kt b/app/src/main/java/app/gamenative/service/DownloadService.kt index c05e72541d..248fe6d843 100644 --- a/app/src/main/java/app/gamenative/service/DownloadService.kt +++ b/app/src/main/java/app/gamenative/service/DownloadService.kt @@ -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 = emptyList() private set @@ -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) } diff --git a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt index f288a6a618..749c0ef47a 100644 --- a/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt +++ b/app/src/main/java/app/gamenative/ui/screen/settings/SettingsGroupInterface.kt @@ -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 @@ -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) } } } @@ -804,4 +804,3 @@ private fun Preview_SettingsScreen() { ) } } - diff --git a/app/src/main/java/app/gamenative/utils/StorageUtils.kt b/app/src/main/java/app/gamenative/utils/StorageUtils.kt index 103290cc1f..232d275800 100644 --- a/app/src/main/java/app/gamenative/utils/StorageUtils.kt +++ b/app/src/main/java/app/gamenative/utils/StorageUtils.kt @@ -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 + } + } + + 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 diff --git a/app/src/test/java/app/gamenative/utils/StorageUtilsTest.kt b/app/src/test/java/app/gamenative/utils/StorageUtilsTest.kt new file mode 100644 index 0000000000..7153e59b90 --- /dev/null +++ b/app/src/test/java/app/gamenative/utils/StorageUtilsTest.kt @@ -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, + ), + ) + } +}