From ee3f32d31e8a5f79d54c716bd108e35634e9fe7d Mon Sep 17 00:00:00 2001 From: ian Date: Mon, 26 Oct 2026 03:38:06 +0700 Subject: [PATCH] feat: default storage for new installs, with USB/SDCARD/SSD detection (#1520) * feat: choose default storage for new store installs Store installs and the Epic Games Launcher can use internal storage, external storage, a folder picked through Choose folder..., or ask every time. The choice is stored in SessionPrefs and used by StoreInstallRoot; Storage > Default storage exposes it. * fix: allow choosing any folder for default storage The default storage choice can now hold an arbitrary folder path, not just internal or app-specific external storage. StoreInstallRoot resolves and validates picked folders, keeps a disconnected choice instead of silently falling back, and the folder picker stays available from Storage. * fix: keep the Epic install folder when external storage is away * test: use a unique missing folder for the Epic storage check * fix: detect USB SSDs that the storage manager hides List storage volumes that have no UUID instead of dropping them, keep volumes that are present but not yet mounted, and fall back to parsing /proc/mounts so USB OTG drives appear even when Android does not expose them as storage volumes or app directories. Prefer the system's volume description for removable drives. (Raw removable drives as move targets, the folder-picker fallback, and the unreadable-drive guidance follow with the move stack.) * fix: compare mount paths as strings in StorageRootsTest * fix: never drop a present storage volume from the drive list Key volumes by description or index when they report no UUID and no directory, keep nofs/unmountable states so a plugged-in drive stays visible with guidance, only attach app directories on strict UUID matches, and accept any fstype under vold-managed locations plus usb-ish mounts directly under /mnt. * fix: probe folder writes with a fresh temp file and keep Epic's folder unset while external storage is away --------- Co-authored-by: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com> --- .../droiddeck/launcher/files/StorageRoots.kt | 87 +++++++++++-- .../droiddeck/launcher/session/GameStorage.kt | 7 +- .../launcher/session/SessionPrefs.kt | 28 +++++ .../launcher/stores/StoreInstallRoot.kt | 76 ++++++++++-- .../droiddeck/launcher/stores/StoresState.kt | 18 ++- .../launcher/stores/epic/EpicClient.kt | 16 ++- .../com/droiddeck/launcher/ui/StoragePage.kt | 117 ++++++++++++++++++ app/src/main/res/values-in/strings.xml | 15 +++ app/src/main/res/values/strings.xml | 15 +++ .../launcher/files/StorageRootsTest.kt | 71 +++++++++++ .../launcher/stores/StoreInstallRootTest.kt | 75 +++++++++++ .../launcher/stores/epic/EpicClientTest.kt | 22 ++++ docs/development/stores.md | 8 +- 13 files changed, 526 insertions(+), 29 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/files/StorageRootsTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/files/StorageRoots.kt b/app/src/main/java/com/droiddeck/launcher/files/StorageRoots.kt index 99e940157..273917b00 100644 --- a/app/src/main/java/com/droiddeck/launcher/files/StorageRoots.kt +++ b/app/src/main/java/com/droiddeck/launcher/files/StorageRoots.kt @@ -29,6 +29,50 @@ object StorageRoots { val seeds = LinkedHashSet() } + private val EXTERNAL_MOUNT_TYPES = setOf( + "vfat", "exfat", "texfat", "ntfs", "ntfs3", "fuseblk", "fuse", "sdcardfs", + ) + + internal data class MountPoint(val target: File, val type: String) + + internal fun parseMountPoints(mounts: String): List { + val out = ArrayList() + for (line in mounts.lineSequence()) { + val parts = line.split(' ') + if (parts.size < 3) continue + out.add(MountPoint(File(parts[1].replace("\\040", " ")), parts[2].lowercase())) + } + return out.distinct() + } + + internal fun isExternalMount(mount: MountPoint): Boolean { + val path = mount.target.path + val name = when { + path.startsWith("/storage/") -> path.removePrefix("/storage/") + path.startsWith("/mnt/media_rw/") -> path.removePrefix("/mnt/media_rw/") + path.startsWith("/mnt/") -> path.removePrefix("/mnt/") + else -> return false + } + if ('/' in name || name.isEmpty() || name == "emulated" || name == "self") return false + if (mount.type in EXTERNAL_MOUNT_TYPES) { + if (path.startsWith("/mnt/") && !path.startsWith("/mnt/media_rw/")) { + val lower = name.lowercase() + if (!lower.contains("usb") && !lower.contains("otg") && + !lower.contains("udisk") && !lower.contains("disk") + ) return false + } + return true + } + // vold-managed locations only hold removable volumes, whatever the fstype is. + return path.startsWith("/storage/") || path.startsWith("/mnt/media_rw/") + } + + internal fun parseExternalMounts(mounts: String): List = + parseMountPoints(mounts).filter(::isExternalMount).map { it.target } + + fun externalMounts(): List = + runCatching { parseExternalMounts(File("/proc/mounts").readText()) }.getOrDefault(emptyList()) + fun list(context: Context): List { val volumes = LinkedHashMap() @@ -44,21 +88,31 @@ object StorageRoots { val storageManager = context.getSystemService(StorageManager::class.java) storageManager?.storageVolumes.orEmpty() - .filter { isMounted(it.state) } - .forEach { volume -> + .filter { present(it.state) } + .forEachIndexed { index, volume -> val uuid = volume.uuid?.takeIf { it.isNotBlank() } - val entry = volumeOf(if (volume.isPrimary) PRIMARY_KEY else uuid ?: return@forEach) + val dirName = volume.directory?.name?.takeIf { it.isNotBlank() } + // Unmounted volumes often report neither uuid nor directory; never drop the + // volume or a plugged-in drive stays invisible with no explanation. + val descSlug = volume.getDescription(context)?.trim() + ?.takeIf { it.isNotBlank() && !it.equals("android", ignoreCase = true) } + ?.replace(Regex("[^A-Za-z0-9._-]+"), "-") + val entry = volumeOf(if (volume.isPrimary) PRIMARY_KEY else uuid ?: dirName ?: descSlug ?: "volume-$index") entry.removable = !volume.isPrimary && volume.isRemovable - if (entry.label == null) entry.label = labelFor(context, volume, uuid) + if (entry.label == null) entry.label = labelFor(context, volume, uuid ?: dirName ?: descSlug) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { volume.directory?.let { entry.candidates += it } } - if (uuid != null) { - entry.candidates += File("/storage/$uuid") - entry.candidates += File("/mnt/media_rw/$uuid") + val name = uuid ?: dirName + if (name != null) { + entry.candidates += File("/storage/$name") + entry.candidates += File("/mnt/media_rw/$name") + // Only strict uuid matches own an app directory; a loose match would + // attach another volume's folder to this entry. + if (uuid != null) appDirs.filter { belongsTo(storageManager!!, it, volume) } + .forEach { entry.seeds += it } } - appDirs.filter { belongsTo(storageManager!!, it, volume) }.forEach { entry.seeds += it } } File("/storage").listFiles().orEmpty().forEach { child -> @@ -68,6 +122,13 @@ object StorageRoots { File("/mnt/media_rw").listFiles().orEmpty().forEach { child -> if (child.isDirectory) volumeOf(child.name).candidates += child } + // USB OTG drives do not always appear as storage volumes or under /storage for + // third-party apps, but their kernel mounts are still visible in /proc/mounts. + for (mount in externalMounts()) { + val entry = volumeOf(mount.name) + if (entry.label == null) entry.label = mount.name + entry.candidates += mount + } appDirs.forEach { dir -> val root = volumeRootOf(dir) ?: return@forEach @@ -141,15 +202,19 @@ object StorageRoots { private fun labelFor(context: Context, volume: StorageVolume, uuid: String?): String { if (volume.isPrimary) return context.getString(R.string.fm_drive_internal) - if (volume.isRemovable) return context.getString(R.string.fm_drive_sd) val description = volume.getDescription(context)?.trim() - return description?.takeIf { it.isNotBlank() && !it.equals("android", ignoreCase = true) } - ?: uuid + if (!description.isNullOrBlank() && !description.equals("android", ignoreCase = true)) return description + if (volume.isRemovable) return context.getString(R.string.fm_drive_sd) + return uuid ?: context.getString(R.string.fm_drive_storage) } private fun isEmulated(dir: File): Boolean = dir.absolutePath.startsWith(EMULATED_PREFIX) + private fun present(state: String?): Boolean = + isMounted(state) || state == Environment.MEDIA_UNMOUNTED || state == Environment.MEDIA_CHECKING || + state == Environment.MEDIA_NOFS || state == Environment.MEDIA_UNMOUNTABLE + private fun isMounted(state: String?): Boolean = state == Environment.MEDIA_MOUNTED || state == Environment.MEDIA_MOUNTED_READ_ONLY diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt b/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt index 90feec16e..447fb254c 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GameStorage.kt @@ -14,9 +14,12 @@ object GameStorage { fun options(context: Context): List