mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-13 03:51:10 +00:00
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>
This commit is contained in:
1 parent
38926b3157
commit
ee3f32d31e
13 files changed
+526
-29
No files matched your search
@@ -29,6 +29,50 @@ object StorageRoots {
|
||||
val seeds = LinkedHashSet<File>()
|
||||
}
|
||||
|
||||
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<MountPoint> {
|
||||
val out = ArrayList<MountPoint>()
|
||||
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<File> =
|
||||
parseMountPoints(mounts).filter(::isExternalMount).map { it.target }
|
||||
|
||||
fun externalMounts(): List<File> =
|
||||
runCatching { parseExternalMounts(File("/proc/mounts").readText()) }.getOrDefault(emptyList())
|
||||
|
||||
fun list(context: Context): List<StorageRoot> {
|
||||
val volumes = LinkedHashMap<String, Volume>()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -14,9 +14,12 @@ object GameStorage {
|
||||
|
||||
fun options(context: Context): List<Option> {
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
val primary = context.getExternalFilesDir(null)?.absolutePath
|
||||
val primaryStorage = Environment.getExternalStorageDirectory()?.absolutePath
|
||||
return context.getExternalFilesDirs(null).orEmpty().filterNotNull().mapNotNull { dir ->
|
||||
val removable = try { Environment.isExternalStorageRemovable(dir) } catch (e: Exception) { false }
|
||||
if (!removable) return@mapNotNull null
|
||||
// Non-primary app dirs are usable even when Android reports the volume as non-removable (USB SSDs often do).
|
||||
if (primary != null && dir.absolutePath == primary) return@mapNotNull null
|
||||
if (!primaryStorage.isNullOrBlank() && dir.absolutePath.startsWith("$primaryStorage/")) return@mapNotNull null
|
||||
val volume = try { sm?.getStorageVolume(dir) } catch (e: Exception) { null }
|
||||
val name = volume?.getDescription(context)?.takeIf { it.isNotBlank() && !it.equals("android", true) } ?: context.getString(R.string.gstore_sd_card)
|
||||
Option(context.getString(R.string.gstore_volume_free, name, free(dir)), File(dir, "steam").absolutePath, name)
|
||||
|
||||
@@ -141,6 +141,34 @@ object SessionPrefs {
|
||||
prefs(context).edit().putString("storesInstallLanguage", code).apply()
|
||||
}
|
||||
|
||||
const val DEFAULT_STORAGE_ASK = "ask"
|
||||
const val DEFAULT_STORAGE_INTERNAL = "internal"
|
||||
const val DEFAULT_STORAGE_EXTERNAL = "external"
|
||||
private val DEFAULT_STORAGE_VALUES = setOf(DEFAULT_STORAGE_ASK, DEFAULT_STORAGE_INTERNAL, DEFAULT_STORAGE_EXTERNAL)
|
||||
|
||||
fun defaultStorage(context: Context): String =
|
||||
prefs(context).getString("defaultStorage", DEFAULT_STORAGE_ASK)
|
||||
?.takeIf { it in DEFAULT_STORAGE_VALUES } ?: DEFAULT_STORAGE_ASK
|
||||
|
||||
fun setDefaultStorage(context: Context, value: String) {
|
||||
prefs(context).edit().putString("defaultStorage", value.takeIf { it in DEFAULT_STORAGE_VALUES } ?: DEFAULT_STORAGE_ASK).apply()
|
||||
}
|
||||
|
||||
fun defaultStorageFolder(context: Context): String = prefs(context).getString("defaultStorageFolder", "") ?: ""
|
||||
|
||||
fun defaultStorageFolderLabel(context: Context): String = prefs(context).getString("defaultStorageFolderLabel", "") ?: ""
|
||||
|
||||
fun setDefaultStorageFolder(context: Context, path: String, label: String) {
|
||||
prefs(context).edit()
|
||||
.putString("defaultStorageFolder", path)
|
||||
.putString("defaultStorageFolderLabel", label)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun clearDefaultStorageFolder(context: Context) {
|
||||
prefs(context).edit().remove("defaultStorageFolder").remove("defaultStorageFolderLabel").apply()
|
||||
}
|
||||
|
||||
fun gameStoresParallel(context: Context): Int = prefs(context).getInt("gameStoresParallel", 1).coerceIn(1, 3)
|
||||
|
||||
fun setGameStoresParallel(context: Context, count: Int) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.os.StatFs
|
||||
import android.os.storage.StorageManager
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import java.io.File
|
||||
import java.util.zip.CRC32
|
||||
|
||||
@@ -76,34 +77,93 @@ object StoreInstallRoot {
|
||||
|
||||
fun targets(context: Context): List<Target> {
|
||||
val internal = Target(internalRoot(context), context.getString(com.droiddeck.launcher.R.string.games_internal), removable = false)
|
||||
val custom = customTarget(context)
|
||||
val cards = GameStorage.options(context).map { Target(File(it.path, ON_VOLUME), it.name, removable = true) }
|
||||
val library = GameStorage.effective(context)?.let { lib ->
|
||||
val root = File(lib.path, ON_VOLUME)
|
||||
if (cards.any { it.root.absolutePath == root.absolutePath }) return@let null
|
||||
val volume = runCatching { context.getSystemService(StorageManager::class.java)?.getStorageVolume(File(lib.path)) }.getOrNull()
|
||||
val name = File(lib.path).name.ifBlank { lib.label }
|
||||
val where = volume?.takeUnless { it.isPrimary }?.getDescription(context)
|
||||
val label = if (where.isNullOrBlank()) name else context.getString(com.droiddeck.launcher.R.string.gstore_folder_on_volume, name, where)
|
||||
Target(root, label, removable = true, folder = true)
|
||||
Target(root, folderLabel(context, File(lib.path)), removable = true, folder = true)
|
||||
}
|
||||
return listOf(internal) + cards + listOfNotNull(library)
|
||||
return (listOf(internal) + listOfNotNull(custom) + cards + listOfNotNull(library))
|
||||
.distinctBy { it.root.absolutePath }
|
||||
}
|
||||
|
||||
fun defaultStorageFolder(context: Context): File? =
|
||||
SessionPrefs.defaultStorageFolder(context).takeIf { it.isNotBlank() }?.let(::File)
|
||||
|
||||
fun customTarget(context: Context): Target? {
|
||||
val folder = defaultStorageFolder(context) ?: return null
|
||||
// Writes are probed when the folder is chosen; File.canWrite() is unreliable on FUSE volumes.
|
||||
if (!folder.isDirectory) return null
|
||||
val label = SessionPrefs.defaultStorageFolderLabel(context).ifBlank { folderLabel(context, folder) }
|
||||
return Target(File(folder, ON_VOLUME), label, removable = true, folder = true)
|
||||
}
|
||||
|
||||
fun detectedExternalTarget(context: Context): Target? {
|
||||
val custom = defaultStorageFolder(context)?.let { File(it, ON_VOLUME) }?.absolutePath
|
||||
return externalTarget(targets(context).filter { it.root.absolutePath != custom }, internalRoot(context))
|
||||
}
|
||||
|
||||
fun installRoot(context: Context): File = internalRoot(context)
|
||||
|
||||
fun externalTarget(context: Context): Target? =
|
||||
if (SessionPrefs.defaultStorageFolder(context).isNotBlank()) customTarget(context)
|
||||
else detectedExternalTarget(context)
|
||||
|
||||
internal fun externalTarget(targets: List<Target>, internalRoot: File): Target? =
|
||||
targets.firstOrNull { it.folder && it.root.absolutePath != internalRoot.absolutePath }
|
||||
?: targets.firstOrNull { it.removable && it.root.absolutePath != internalRoot.absolutePath }
|
||||
|
||||
fun defaultTarget(context: Context): Target? = when (SessionPrefs.defaultStorage(context)) {
|
||||
SessionPrefs.DEFAULT_STORAGE_INTERNAL -> Target(internalRoot(context), context.getString(com.droiddeck.launcher.R.string.games_internal), removable = false)
|
||||
SessionPrefs.DEFAULT_STORAGE_EXTERNAL -> externalTarget(context)
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun libraryRoot(context: Context): File? = GameStorage.effective(context)?.let { File(it.path, ON_VOLUME) }
|
||||
|
||||
fun roots(context: Context): List<File> {
|
||||
val cards = GameStorage.options(context).map { File(it.path, ON_VOLUME) }
|
||||
val custom = defaultStorageFolder(context)?.takeIf { it.isDirectory }?.let { File(it, ON_VOLUME) }
|
||||
val library = GameStorage.effective(context)?.let { File(it.path, ON_VOLUME) }
|
||||
return (listOf(internalRoot(context)) + cards + listOfNotNull(library)).distinctBy { it.absolutePath }
|
||||
return (listOf(internalRoot(context)) + cards + listOfNotNull(custom) + listOfNotNull(library))
|
||||
.distinctBy { it.absolutePath }
|
||||
}
|
||||
|
||||
fun externalRoots(context: Context): List<Pair<File, String>> {
|
||||
val library = GameStorage.effective(context)?.path
|
||||
val used = mutableSetOf<String>()
|
||||
return roots(context)
|
||||
.filter { !isInternal(context, it) && (library == null || !it.absolutePath.startsWith("$library/")) }
|
||||
.map { it to guestFor(context, it) }
|
||||
.map { root ->
|
||||
val base = guestFor(context, root)
|
||||
val guest = if (used.add(base)) base else {
|
||||
val suffix = "%08x".format(CRC32().apply { update(root.absolutePath.toByteArray()) }.value)
|
||||
"$base-$suffix".also { used.add(it) }
|
||||
}
|
||||
root to guest
|
||||
}
|
||||
}
|
||||
|
||||
fun folderLabel(context: Context, folder: File): String {
|
||||
val name = folder.name.ifBlank { context.getString(com.droiddeck.launcher.R.string.gstore_folder) }
|
||||
val volume = runCatching {
|
||||
context.getSystemService(StorageManager::class.java)?.getStorageVolume(folder)?.getDescription(context)
|
||||
}.getOrNull()
|
||||
return if (volume.isNullOrBlank() || volume.equals("android", true) || volume.equals(name, true)) name
|
||||
else context.getString(com.droiddeck.launcher.R.string.gstore_folder_on_volume, name, volume)
|
||||
}
|
||||
|
||||
fun prepareDefaultFolder(context: Context, folder: File): String? {
|
||||
if (!folder.isDirectory) return context.getString(com.droiddeck.launcher.R.string.gstore_cannot_create, folder.path)
|
||||
val root = File(folder, ON_VOLUME)
|
||||
if (!root.isDirectory && !root.mkdirs()) return context.getString(com.droiddeck.launcher.R.string.gstore_cannot_create, folder.path)
|
||||
return try {
|
||||
File.createTempFile(".droiddeck-write-probe", null, root).delete()
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
context.getString(com.droiddeck.launcher.R.string.gstore_cannot_write_detail, root.path, e.message)
|
||||
}
|
||||
}
|
||||
|
||||
/** Xodus has its own PRoot mount table and cannot see the selected Steam library's SD bind. */
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.stores.download.DownloadEntry
|
||||
|
||||
object StoresState {
|
||||
@@ -220,17 +221,26 @@ object StoresState {
|
||||
if (item.store == Store.ITCH && item.extra["uploadId"].isNullOrEmpty()) { itchPicking = item; return }
|
||||
if (isUnfinished(item)) { install(context, item); return }
|
||||
val targets = StoreInstallRoot.targets(context)
|
||||
if (targets.size > 1 || StoreLanguage.selectable(item.store)) pendingInstall = item
|
||||
else install(context, item, targets.first().root)
|
||||
val preferred = StoreInstallRoot.defaultTarget(context)
|
||||
val wantedExternal = SessionPrefs.defaultStorage(context) == SessionPrefs.DEFAULT_STORAGE_EXTERNAL
|
||||
if (preferred != null) SessionPrefs.setStoresInstallTarget(context, preferred.root.absolutePath)
|
||||
when {
|
||||
preferred != null && !StoreLanguage.selectable(item.store) -> {
|
||||
install(context, item, preferred.root)
|
||||
}
|
||||
targets.size > 1 || wantedExternal || StoreLanguage.selectable(item.store) -> pendingInstall = item
|
||||
else -> install(context, item, targets.first().root)
|
||||
}
|
||||
}
|
||||
|
||||
fun install(context: Context, item: CatalogItem, root: java.io.File? = null, language: String? = null) {
|
||||
val app = context.applicationContext
|
||||
val targetRoot = root ?: StoreInstallRoot.defaultTarget(app)?.root ?: StoreInstallRoot.installRoot(app)
|
||||
if (item.store == Store.BATTLENET) {
|
||||
backends[item.store]?.install(app, item, root ?: StoreInstallRoot.installRoot(app))
|
||||
backends[item.store]?.install(app, item, targetRoot)
|
||||
return
|
||||
}
|
||||
val folder = StoreInstallRoot.folderFor(app, item.store, item.id, item.title, root ?: StoreInstallRoot.installRoot(app))
|
||||
val folder = StoreInstallRoot.folderFor(app, item.store, item.id, item.title, targetRoot)
|
||||
val target = folder.parentFile!!.parentFile!!
|
||||
val code = language?.takeIf { it.isNotBlank() } ?: StoreLanguage.preferredCode(app)
|
||||
val tagged = if (StoreLanguage.selectable(item.store)) item.copy(extra = item.extra + (StoreLanguage.EXTRA to code)) else item
|
||||
|
||||
@@ -292,10 +292,24 @@ object EpicClient {
|
||||
AddedGames.guestPath(app, folder)?.let { t.root.absolutePath to (folder to it) }
|
||||
}.toMap()
|
||||
if (dirs.isEmpty()) return null
|
||||
val chosen = dirs[SessionPrefs.storesInstallTarget(app)] ?: dirs[StoreInstallRoot.internalRoot(app).absolutePath] ?: dirs.values.first()
|
||||
val preferred = StoreInstallRoot.defaultTarget(app)?.root?.absolutePath
|
||||
val chosen = preferred?.let { dirs[it] }
|
||||
?: dirs[SessionPrefs.storesInstallTarget(app)]
|
||||
?: dirs[StoreInstallRoot.internalRoot(app).absolutePath]
|
||||
?: dirs.values.first()
|
||||
val file = File(dataDir(app), SETTINGS)
|
||||
val settings = runCatching { JSONObject(file.readText()) }.getOrNull() ?: JSONObject()
|
||||
val current = settings.optString("defaultInstallDir").trimEnd('/')
|
||||
val externalMissing = SessionPrefs.defaultStorage(app) == SessionPrefs.DEFAULT_STORAGE_EXTERNAL && preferred == null
|
||||
if (externalMissing && current.isNotEmpty() &&
|
||||
(current.startsWith("/mnt/droiddeck-") || current.startsWith("/root/Storage/"))) {
|
||||
Log.w(TAG, "the configured external storage is disconnected; keeping $current")
|
||||
return current
|
||||
}
|
||||
if (externalMissing && current.isEmpty()) {
|
||||
Log.w(TAG, "the configured external storage is disconnected; not choosing an install folder")
|
||||
return null
|
||||
}
|
||||
if (current.isNotEmpty() && current != STOCK_INSTALL_DIR && dirs.values.none { it.second == current } && !(current.endsWith("/${Store.EPIC.shortLabel}") && (current.startsWith("/mnt/droiddeck-") || current.startsWith("/root/Storage/")))) return current
|
||||
if (!chosen.first.isDirectory && !chosen.first.mkdirs()) return null
|
||||
if (current == chosen.second) return current
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
package com.droiddeck.launcher.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
@@ -51,6 +55,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
@@ -65,6 +70,7 @@ import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
@@ -74,6 +80,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.files.InAppFilePicker
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import com.droiddeck.launcher.storage.Bucket
|
||||
import com.droiddeck.launcher.storage.CleanItem
|
||||
@@ -84,6 +91,8 @@ import com.droiddeck.launcher.storage.StorageState
|
||||
import com.droiddeck.launcher.storage.ToolReason
|
||||
import com.droiddeck.launcher.storage.ToolState
|
||||
import com.droiddeck.launcher.storage.ToolUsage
|
||||
import com.droiddeck.launcher.stores.StoreInstallRoot
|
||||
import java.io.File
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
@@ -144,6 +153,113 @@ internal fun StoragePanel(s: FrontEndState, a: FrontEndActions) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DefaultStorageGroup() {
|
||||
val ctx = LocalContext.current
|
||||
var value by remember { mutableStateOf(SessionPrefs.defaultStorage(ctx)) }
|
||||
var open by remember { mutableStateOf(false) }
|
||||
var revision by remember { mutableIntStateOf(0) }
|
||||
val customPath = SessionPrefs.defaultStorageFolder(ctx)
|
||||
val customTarget = remember(customPath, revision) { StoreInstallRoot.customTarget(ctx) }
|
||||
val detected = remember(customPath, revision) { StoreInstallRoot.detectedExternalTarget(ctx) }
|
||||
val customLabel = SessionPrefs.defaultStorageFolderLabel(ctx).ifBlank {
|
||||
customPath.takeIf { it.isNotBlank() }?.let { File(it).name }.orEmpty()
|
||||
}
|
||||
val pickFolder = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult
|
||||
val path = InAppFilePicker.pickedPath(result.data) ?: return@rememberLauncherForActivityResult
|
||||
val problem = StoreInstallRoot.prepareDefaultFolder(ctx, File(path))
|
||||
if (problem != null) {
|
||||
Toast.makeText(ctx, ctx.getString(R.string.main_storage_not_usable, problem), Toast.LENGTH_LONG).show()
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
SessionPrefs.setDefaultStorageFolder(ctx, path, StoreInstallRoot.folderLabel(ctx, File(path)))
|
||||
SessionPrefs.setDefaultStorage(ctx, SessionPrefs.DEFAULT_STORAGE_EXTERNAL)
|
||||
value = SessionPrefs.DEFAULT_STORAGE_EXTERNAL
|
||||
revision++
|
||||
open = false
|
||||
}
|
||||
fun chooseFolder() {
|
||||
open = false
|
||||
val initial = customPath.takeIf { it.isNotBlank() && File(it).isDirectory }
|
||||
?: detected?.root?.parentFile?.takeIf { it.isDirectory }?.absolutePath
|
||||
pickFolder.launch(InAppFilePicker.buildDirIntent(ctx, ctx.getString(R.string.storage_default_choose_folder), initial))
|
||||
}
|
||||
val title = stringResource(R.string.storage_default_title)
|
||||
val chip = when (value) {
|
||||
SessionPrefs.DEFAULT_STORAGE_INTERNAL -> stringResource(R.string.storage_default_internal)
|
||||
SessionPrefs.DEFAULT_STORAGE_EXTERNAL -> when {
|
||||
customPath.isNotBlank() -> customTarget?.label
|
||||
?: "$customLabel · ${stringResource(R.string.storage_default_folder_missing)}"
|
||||
detected != null -> detected.label
|
||||
else -> stringResource(R.string.storage_default_external)
|
||||
}
|
||||
else -> stringResource(R.string.storage_default_ask)
|
||||
}
|
||||
fun pick(next: String) {
|
||||
SessionPrefs.setDefaultStorage(ctx, next)
|
||||
value = next
|
||||
open = false
|
||||
}
|
||||
SettingsGroup(stringResource(R.string.storage_default_group)) {
|
||||
SettingsRow(label = title, hint = stringResource(R.string.storage_default_hint)) {
|
||||
Box {
|
||||
ValueChip(chip, open, modifier = Modifier.testTag("setting-default-storage")) { open = !open }
|
||||
AnchoredMenu(
|
||||
open, onDismiss = { open = false }, title = title,
|
||||
note = stringResource(R.string.storage_default_note),
|
||||
) { firstItemFocus ->
|
||||
MenuItem(
|
||||
stringResource(R.string.storage_default_ask), value == SessionPrefs.DEFAULT_STORAGE_ASK,
|
||||
detail = stringResource(R.string.storage_default_ask_detail),
|
||||
focusRequester = firstItemFocus,
|
||||
) { pick(SessionPrefs.DEFAULT_STORAGE_ASK) }
|
||||
MenuItem(
|
||||
stringResource(R.string.storage_default_internal), value == SessionPrefs.DEFAULT_STORAGE_INTERNAL,
|
||||
) { pick(SessionPrefs.DEFAULT_STORAGE_INTERNAL) }
|
||||
if (detected == null && customPath.isBlank()) {
|
||||
MenuItem(
|
||||
stringResource(R.string.storage_default_external), value == SessionPrefs.DEFAULT_STORAGE_EXTERNAL,
|
||||
detail = stringResource(R.string.storage_default_choose_folder_detail),
|
||||
) { chooseFolder() }
|
||||
} else {
|
||||
if (detected != null) {
|
||||
MenuItem(
|
||||
stringResource(R.string.storage_default_external),
|
||||
value == SessionPrefs.DEFAULT_STORAGE_EXTERNAL && customPath.isBlank(),
|
||||
detail = detected.let { target ->
|
||||
val free = target.freeBytes
|
||||
if (free >= 0) stringResource(R.string.storage_default_external_detail, target.label, gb(free))
|
||||
else target.label
|
||||
},
|
||||
) {
|
||||
SessionPrefs.clearDefaultStorageFolder(ctx)
|
||||
pick(SessionPrefs.DEFAULT_STORAGE_EXTERNAL)
|
||||
revision++
|
||||
}
|
||||
}
|
||||
if (customPath.isNotBlank()) {
|
||||
MenuItem(
|
||||
stringResource(R.string.storage_default_folder, customLabel),
|
||||
value == SessionPrefs.DEFAULT_STORAGE_EXTERNAL,
|
||||
detail = customTarget?.let { target ->
|
||||
val free = target.freeBytes
|
||||
if (free >= 0) stringResource(R.string.storage_default_folder_free, gb(free))
|
||||
else stringResource(R.string.storage_default_folder_missing)
|
||||
} ?: stringResource(R.string.storage_default_folder_missing),
|
||||
) { if (customTarget != null) pick(SessionPrefs.DEFAULT_STORAGE_EXTERNAL) else chooseFolder() }
|
||||
}
|
||||
MenuItem(
|
||||
stringResource(R.string.storage_default_choose_folder), checked = false,
|
||||
detail = stringResource(R.string.storage_default_choose_folder_detail),
|
||||
) { chooseFolder() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun Overview(report: StorageReport, sessionRunning: Boolean, onCleanUp: () -> Unit, onOpen: (String) -> Unit) {
|
||||
@@ -189,6 +305,7 @@ private fun Overview(report: StorageReport, sessionRunning: Boolean, onCleanUp:
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Note(stringResource(R.string.storage_session_running))
|
||||
}
|
||||
DefaultStorageGroup()
|
||||
SectionTitle(stringResource(R.string.storage_where), null)
|
||||
val unused = report.unusedTools.sumOf { it.bytes }
|
||||
val tiles = listOf<@Composable (Modifier) -> Unit>(
|
||||
|
||||
@@ -2562,6 +2562,21 @@
|
||||
<string name="storage_safe_hint">Runtime yang tidak dipakai, sisa, log lama</string>
|
||||
<string name="storage_clean_up">Membersihkan…</string>
|
||||
<string name="storage_session_running">Hentikan sesi untuk membersihkan atau menghapus apa pun. Ukuran tetap terlihat.</string>
|
||||
<string name="storage_default_group">Lokasi instalasi</string>
|
||||
<string name="storage_default_title">Penyimpanan default</string>
|
||||
<string name="storage_default_hint">Instalasi toko baru akan disimpan di lokasi yang dipilih. Game yang sudah ada tetap di lokasinya. Library Steam memakai pengaturannya sendiri.</string>
|
||||
<string name="storage_default_ask">Tanyakan setiap kali</string>
|
||||
<string name="storage_default_ask_detail">Pilih target untuk setiap instalasi</string>
|
||||
<string name="storage_default_internal">Penyimpanan internal</string>
|
||||
<string name="storage_default_external">Penyimpanan eksternal</string>
|
||||
<string name="storage_default_external_detail">%1$s · %2$s tersedia</string>
|
||||
<string name="storage_default_external_missing">Tidak terhubung</string>
|
||||
<string name="storage_default_folder">Folder · %1$s</string>
|
||||
<string name="storage_default_folder_free">%1$s tersedia</string>
|
||||
<string name="storage_default_folder_missing">Tidak terhubung</string>
|
||||
<string name="storage_default_choose_folder">Pilih folder…</string>
|
||||
<string name="storage_default_choose_folder_detail">Gunakan folder apa pun di kartu SD atau drive USB</string>
|
||||
<string name="storage_default_note">Game akan disimpan di folder Games di dalam lokasi yang dipilih. Game yang sudah terpasang tidak dipindahkan.</string>
|
||||
<string name="storage_where">Ke mana perginya</string>
|
||||
<string name="storage_games">Gim Steam</string>
|
||||
<string name="storage_game_data">Data gim</string>
|
||||
|
||||
@@ -2731,6 +2731,21 @@
|
||||
<string name="storage_safe_hint">Unused runtimes, leftovers, old logs</string>
|
||||
<string name="storage_clean_up">Clean up…</string>
|
||||
<string name="storage_session_running">Stop the session to clean up or remove anything. Sizes are still shown.</string>
|
||||
<string name="storage_default_group">Install location</string>
|
||||
<string name="storage_default_title">Default storage</string>
|
||||
<string name="storage_default_hint">New store installs go to the selected location. Existing games stay where they are. Steam libraries keep their own setting.</string>
|
||||
<string name="storage_default_ask">Ask every time</string>
|
||||
<string name="storage_default_ask_detail">Choose a target for each install</string>
|
||||
<string name="storage_default_internal">Internal storage</string>
|
||||
<string name="storage_default_external">External storage</string>
|
||||
<string name="storage_default_external_detail">%1$s · %2$s free</string>
|
||||
<string name="storage_default_external_missing">Not connected</string>
|
||||
<string name="storage_default_folder">Folder · %1$s</string>
|
||||
<string name="storage_default_folder_free">%1$s free</string>
|
||||
<string name="storage_default_folder_missing">Not connected</string>
|
||||
<string name="storage_default_choose_folder">Choose folder…</string>
|
||||
<string name="storage_default_choose_folder_detail">Use any folder on an SD card or USB drive</string>
|
||||
<string name="storage_default_note">Games go into a Games folder inside the selected location. Existing games are not moved.</string>
|
||||
<string name="storage_where">Where it goes</string>
|
||||
<string name="storage_games">Steam games</string>
|
||||
<string name="storage_game_data">Game data</string>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.droiddeck.launcher.files
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class StorageRootsTest {
|
||||
@Test fun usbAndSdMountsAreFound() {
|
||||
val mounts = """
|
||||
/dev/fuse /mnt/user/0/emulated fuse rw,nosuid,nodev 0 0
|
||||
/dev/block/vold/public:8,1 /mnt/media_rw/A1B2-C3D4 vfat rw,dirsync 0 0
|
||||
/mnt/media_rw/A1B2-C3D4 /storage/A1B2-C3D4 fuse rw,nosuid,nodev 0 0
|
||||
/dev/block/vold/public:179,65 /mnt/media_rw/9C33-1A2B exfat rw,dirsync 0 0
|
||||
/mnt/media_rw/9C33-1A2B /storage/9C33-1A2B fuse rw,nosuid,nodev 0 0
|
||||
/dev/block/dm-5 /data ext4 rw,seclabel 0 0
|
||||
""".trimIndent()
|
||||
|
||||
val points = StorageRoots.parseExternalMounts(mounts).map { it.path }
|
||||
|
||||
assertTrue(points.contains("/storage/A1B2-C3D4"))
|
||||
assertTrue(points.contains("/storage/9C33-1A2B"))
|
||||
assertTrue(points.contains("/mnt/media_rw/A1B2-C3D4"))
|
||||
}
|
||||
|
||||
@Test fun voldLocationsCountWhateverTheFstypeIs() {
|
||||
val mounts = """
|
||||
/dev/block/sda1 /storage/0D19-CC0A fuse rw,nosuid,nodev 0 0
|
||||
/dev/block/sda1 /mnt/media_rw/0D19-CC0A exfat rw,dirsync 0 0
|
||||
""".trimIndent()
|
||||
|
||||
val points = StorageRoots.parseExternalMounts(mounts).map { it.path }
|
||||
|
||||
assertTrue(points.contains("/storage/0D19-CC0A"))
|
||||
assertTrue(points.contains("/mnt/media_rw/0D19-CC0A"))
|
||||
}
|
||||
|
||||
@Test fun usbMountsDirectlyUnderMntAreFound() {
|
||||
val mounts = """
|
||||
/dev/block/sda1 /mnt/usb_storage exfat rw,dirsync 0 0
|
||||
tmpfs /mnt/media_rw tmpfs rw,seclabel 0 0
|
||||
/dev/fuse /mnt/user/0/emulated fuse rw,nosuid,nodev 0 0
|
||||
""".trimIndent()
|
||||
|
||||
val points = StorageRoots.parseExternalMounts(mounts).map { it.path }
|
||||
|
||||
assertTrue(points.contains("/mnt/usb_storage"))
|
||||
assertEquals(1, points.size)
|
||||
}
|
||||
|
||||
@Test fun emulatedAndSystemMountsAreIgnored() {
|
||||
val mounts = """
|
||||
/dev/fuse /mnt/user/0/emulated fuse rw,nosuid,nodev 0 0
|
||||
/mnt/media_rw/emulated /storage/emulated sdcardfs rw,nosuid,nodev 0 0
|
||||
tmpfs /storage/self tmpfs rw,seclabel 0 0
|
||||
overlay /system ext4 ro,seclabel 0 0
|
||||
/dev/block/sda1 /data ext4 rw,seclabel 0 0
|
||||
""".trimIndent()
|
||||
|
||||
assertEquals(emptyList<String>(), StorageRoots.parseExternalMounts(mounts).map { it.path })
|
||||
}
|
||||
|
||||
@Test fun escapedSpacesAndNtfsAreHandled() {
|
||||
val mounts = "/dev/block/vold/public:8,1 /mnt/media_rw/My\\040USB ntfs rw,dirsync 0 0\n" +
|
||||
"/mnt/media_rw/My\\040USB /storage/My\\040USB fuse rw,nosuid,nodev 0 0\n"
|
||||
|
||||
val points = StorageRoots.parseExternalMounts(mounts).map { it.path }
|
||||
|
||||
assertTrue(points.contains("/mnt/media_rw/My USB"))
|
||||
assertTrue(points.contains("/storage/My USB"))
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,10 @@ import com.droiddeck.launcher.session.SessionPrefs
|
||||
import java.io.File
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
@@ -18,6 +21,12 @@ import org.robolectric.shadows.ShadowStatFs
|
||||
class StoreInstallRootTest {
|
||||
private val app get() = RuntimeEnvironment.getApplication()
|
||||
|
||||
@Before fun resetStoragePreferences() {
|
||||
SessionPrefs.setDefaultStorageFolder(app, "", "")
|
||||
SessionPrefs.setDefaultStorage(app, SessionPrefs.DEFAULT_STORAGE_ASK)
|
||||
SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "")
|
||||
}
|
||||
|
||||
@Test fun theDefaultRootIsInternalWhateverTheSteamLibrarySettingSays() {
|
||||
val card = File(app.filesDir, "fake-card/steam").apply { mkdirs() }
|
||||
SessionPrefs.setGameStorage(app, card.path, "Card")
|
||||
@@ -26,6 +35,72 @@ class StoreInstallRootTest {
|
||||
assertFalse(StoreInstallRoot.targets(app).first().removable)
|
||||
}
|
||||
|
||||
@Test fun defaultStoragePicksTheInternalOrTheConfiguredExternalTarget() {
|
||||
val library = File(app.filesDir, "default-card/steam").apply { mkdirs() }
|
||||
SessionPrefs.setGameStorage(app, library.path, "Card")
|
||||
val external = File(library, "Games")
|
||||
|
||||
SessionPrefs.setDefaultStorage(app, SessionPrefs.DEFAULT_STORAGE_INTERNAL)
|
||||
assertEquals(StoreInstallRoot.internalRoot(app).absolutePath, StoreInstallRoot.defaultTarget(app)?.root?.absolutePath)
|
||||
|
||||
SessionPrefs.setDefaultStorage(app, SessionPrefs.DEFAULT_STORAGE_EXTERNAL)
|
||||
assertEquals(external.absolutePath, StoreInstallRoot.defaultTarget(app)?.root?.absolutePath)
|
||||
|
||||
SessionPrefs.setDefaultStorage(app, SessionPrefs.DEFAULT_STORAGE_ASK)
|
||||
assertNull(StoreInstallRoot.defaultTarget(app))
|
||||
|
||||
SessionPrefs.setDefaultStorage(app, "bogus")
|
||||
assertEquals(SessionPrefs.DEFAULT_STORAGE_ASK, SessionPrefs.defaultStorage(app))
|
||||
}
|
||||
|
||||
@Test fun defaultExternalStorageFallsBackToTheInstallDialogWhenNoCardIsPresent() {
|
||||
SessionPrefs.setGameStorage(app, SessionPrefs.GAME_STORAGE_OFF, "")
|
||||
SessionPrefs.setDefaultStorage(app, SessionPrefs.DEFAULT_STORAGE_EXTERNAL)
|
||||
assertNull(StoreInstallRoot.defaultTarget(app))
|
||||
}
|
||||
|
||||
@Test fun aChosenFolderIsUsedForDefaultExternalStorage() {
|
||||
val folder = File(app.filesDir, "usb/SSD").apply { mkdirs() }
|
||||
assertNull(StoreInstallRoot.prepareDefaultFolder(app, folder))
|
||||
val root = File(folder, "Games")
|
||||
assertTrue(root.isDirectory)
|
||||
SessionPrefs.setDefaultStorageFolder(app, folder.path, "SSD")
|
||||
SessionPrefs.setDefaultStorage(app, SessionPrefs.DEFAULT_STORAGE_EXTERNAL)
|
||||
|
||||
assertEquals(root.absolutePath, StoreInstallRoot.defaultTarget(app)?.root?.absolutePath)
|
||||
assertTrue(StoreInstallRoot.targets(app).any { it.root.absolutePath == root.absolutePath && it.folder })
|
||||
assertTrue(StoreInstallRoot.roots(app).any { it.absolutePath == root.absolutePath })
|
||||
assertTrue(StoreInstallRoot.externalRoots(app).any { it.first.absolutePath == root.absolutePath })
|
||||
assertFalse(File(root, ".droiddeck-write-probe").exists())
|
||||
}
|
||||
|
||||
@Test fun aDisconnectedChosenFolderDoesNotFallBackToAnotherTarget() {
|
||||
val card = File(app.filesDir, "card/steam").apply { mkdirs() }
|
||||
SessionPrefs.setGameStorage(app, card.path, "Card")
|
||||
val folder = File(app.filesDir, "usb/SSD").apply { mkdirs() }
|
||||
SessionPrefs.setDefaultStorageFolder(app, folder.path, "SSD")
|
||||
SessionPrefs.setDefaultStorage(app, SessionPrefs.DEFAULT_STORAGE_EXTERNAL)
|
||||
assertNotNull(StoreInstallRoot.defaultTarget(app))
|
||||
|
||||
assertTrue(folder.delete())
|
||||
assertNull(StoreInstallRoot.defaultTarget(app))
|
||||
assertEquals(File(card, "Games").absolutePath, StoreInstallRoot.detectedExternalTarget(app)?.root?.absolutePath)
|
||||
}
|
||||
|
||||
@Test fun filesOnAChosenFolderAreMappedIntoTheGuest() {
|
||||
val folder = File(app.filesDir, "usb/SSD").apply { mkdirs() }
|
||||
SessionPrefs.setDefaultStorageFolder(app, folder.path, "SSD")
|
||||
val game = File(folder, "Games/GOG/Test/game.exe").apply {
|
||||
parentFile?.mkdirs()
|
||||
writeText("MZ")
|
||||
}
|
||||
|
||||
val guest = StoreInstallRoot.externalGuestPath(app, game)
|
||||
assertNotNull(guest)
|
||||
assertTrue(guest!!.startsWith(StoreInstallRoot.GUEST_EXTERNAL + "/"))
|
||||
assertTrue(guest.endsWith("/GOG/Test/game.exe"))
|
||||
}
|
||||
|
||||
@Test fun theSteamLibraryFolderIsOfferedAsAnInstallTarget() {
|
||||
val library = File(app.filesDir, "Documents/Games").apply { mkdirs() }
|
||||
SessionPrefs.setGameStorage(app, library.path, "Games")
|
||||
|
||||
@@ -456,4 +456,26 @@ class EpicClientTest {
|
||||
settings.writeText("""{"defaultInstallDir":"/mnt/droiddeck-sd/Stores/Epic"}""")
|
||||
assertEquals("/root/Games/Stores/Epic", EpicClient.prepareInstallDir(context))
|
||||
}
|
||||
|
||||
@Test fun aDisconnectedDefaultFolderKeepsTheLauncherInstallDir() {
|
||||
val context = org.robolectric.RuntimeEnvironment.getApplication()
|
||||
val previousStorage = com.droiddeck.launcher.session.SessionPrefs.defaultStorage(context)
|
||||
val previousFolder = com.droiddeck.launcher.session.SessionPrefs.defaultStorageFolder(context)
|
||||
val previousLabel = com.droiddeck.launcher.session.SessionPrefs.defaultStorageFolderLabel(context)
|
||||
val settings = File(EpicClient.dataDir(context), "settings/app-settings.json").apply { parentFile!!.mkdirs() }
|
||||
val current = "/mnt/droiddeck-stores/1234/Epic"
|
||||
settings.writeText("""{"defaultInstallDir":"$current"}""")
|
||||
com.droiddeck.launcher.session.SessionPrefs.setDefaultStorage(
|
||||
context, com.droiddeck.launcher.session.SessionPrefs.DEFAULT_STORAGE_EXTERNAL)
|
||||
com.droiddeck.launcher.session.SessionPrefs.setDefaultStorageFolder(
|
||||
context, File(context.filesDir, "missing-${System.nanoTime()}").path, "SSD")
|
||||
try {
|
||||
assertEquals(current, EpicClient.prepareInstallDir(context))
|
||||
assertEquals(current, org.json.JSONObject(settings.readText()).getString("defaultInstallDir"))
|
||||
} finally {
|
||||
com.droiddeck.launcher.session.SessionPrefs.setDefaultStorage(context, previousStorage)
|
||||
if (previousFolder.isBlank()) com.droiddeck.launcher.session.SessionPrefs.clearDefaultStorageFolder(context)
|
||||
else com.droiddeck.launcher.session.SessionPrefs.setDefaultStorageFolder(context, previousFolder, previousLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,11 @@ Library pages are `app:<appId>` for an installed game and `app:<store>:<id>` for
|
||||
```
|
||||
|
||||
The default root is the app's internal storage - the runtime's tree, `/root/Games/Stores`, no bind
|
||||
needed - whatever the session's Game storage setting says. When a card the app may write is in the
|
||||
device (`GameStorage.options`), Install asks "Install to" (internal / the card, free space shown;
|
||||
the last pick is only the dialog's default). A card's root is `<card app folder>/Games`; when the
|
||||
needed - whatever the session's Game storage setting says. **Setup > Storage > Default storage**
|
||||
can send new installs straight to internal storage or the first external target; with **Ask every
|
||||
time**, Install asks "Install to" when more than one target exists (internal / the card, free space
|
||||
shown; the last pick is only the dialog's default). An existing install is always resumed in place,
|
||||
and changing the default does not move it. A card's root is `<card app folder>/Games`; when the
|
||||
card is the Steam library the library's bind (`/mnt/droiddeck-sd`) already covers it, otherwise
|
||||
`SessionService` binds the root at `/mnt/droiddeck-stores/<volume uuid>`, so a shortcut's guest
|
||||
path - and its appid - is the same from one session to the next. Every root is
|
||||
|
||||
Reference in new issue
Block a user