diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstallRoot.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstallRoot.kt
index 468f9241..a7cb3f15 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/StoreInstallRoot.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/StoreInstallRoot.kt
@@ -26,7 +26,80 @@ object StoreInstallRoot {
/** One place a game can be installed: the folder, its name for the dialog, and whether it is a card. */
class Target(val root: File, val label: String, val removable: Boolean) {
- val freeBytes: Long get() = try { StatFs((if (root.isDirectory) root else root.parentFile ?: root).path).availableBytes } catch (e: Exception) { 0L }
+ val freeBytes: Long get() = availableBytes(root)
+ }
+
+ data class StorageCheck(
+ val enough: Boolean,
+ val requiredBytes: Long,
+ val availableBytes: Long,
+ val internalRequiredBytes: Long = 0L,
+ val internalAvailableBytes: Long = 0L,
+ val isInternalShortage: Boolean = false,
+ val errorMessage: String? = null
+ )
+
+ fun availableBytes(file: File): Long = try {
+ val dir = if (file.isDirectory) file else (file.parentFile ?: file)
+ if (!dir.exists()) dir.mkdirs()
+ StatFs(if (dir.exists()) dir.path else file.path).availableBytes
+ } catch (e: Exception) {
+ try { file.usableSpace } catch (ignored: Exception) { 0L }
+ }
+
+ /**
+ * Estimated bytes needed for an install. Epic on internal storage needs both chunk cache
+ * and assembled files simultaneously (~2x game size).
+ */
+ fun estimatedBytesNeeded(item: CatalogItem, targetRoot: File, isInternal: Boolean): Long {
+ val size = StoreSizes.size(item)
+ if (size <= 0L) return 0L
+ return if (item.store == Store.EPIC && isInternal) size * 2 else size
+ }
+
+ /** Pre-install storage check to detect insufficient space before starting. */
+ fun checkStorage(context: Context, item: CatalogItem, targetRoot: File): StorageCheck {
+ val size = StoreSizes.size(item)
+ val freeTarget = availableBytes(targetRoot)
+ if (size <= 0L) return StorageCheck(enough = true, requiredBytes = 0L, availableBytes = freeTarget)
+ val internal = isInternal(context, targetRoot)
+ if (internal) {
+ val needed = if (item.store == Store.EPIC) size * 2 else size
+ if (freeTarget in 1 until needed) {
+ val msg = if (item.store == Store.EPIC) {
+ context.getString(
+ com.droiddeck.launcher.R.string.stores_storage_insufficient,
+ formatBytes(needed),
+ formatBytes(freeTarget)
+ ) + " (" + formatBytes(size) + " + " + formatBytes(size) + " " + context.getString(com.droiddeck.launcher.R.string.stores_stage_download).lowercase() + ")"
+ } else {
+ context.getString(com.droiddeck.launcher.R.string.stores_storage_insufficient, formatBytes(needed), formatBytes(freeTarget))
+ }
+ return StorageCheck(enough = false, requiredBytes = needed, availableBytes = freeTarget, errorMessage = msg)
+ }
+ return StorageCheck(enough = true, requiredBytes = needed, availableBytes = freeTarget)
+ } else {
+ if (freeTarget in 1 until size) {
+ val msg = context.getString(com.droiddeck.launcher.R.string.stores_storage_insufficient, formatBytes(size), formatBytes(freeTarget))
+ return StorageCheck(enough = false, requiredBytes = size, availableBytes = freeTarget, errorMessage = msg)
+ }
+ if (item.store == Store.EPIC) {
+ val internalFree = availableBytes(context.cacheDir)
+ if (internalFree in 1 until size) {
+ val msg = context.getString(com.droiddeck.launcher.R.string.stores_storage_internal_cache_insufficient, formatBytes(size), formatBytes(internalFree))
+ return StorageCheck(
+ enough = false,
+ requiredBytes = size,
+ availableBytes = freeTarget,
+ internalRequiredBytes = size,
+ internalAvailableBytes = internalFree,
+ isInternalShortage = true,
+ errorMessage = msg
+ )
+ }
+ }
+ return StorageCheck(enough = true, requiredBytes = size, availableBytes = freeTarget)
+ }
}
/** The internal root: always there, always the default. */
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt
index 4f1cb172..5e8caf30 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt
@@ -220,6 +220,30 @@ object StoresState {
fun install(context: Context, item: CatalogItem, root: java.io.File? = null) {
val app = context.applicationContext
val target = root ?: StoreInstallRoot.installRoot(app)
+ val check = StoreInstallRoot.checkStorage(app, item, target)
+ if (!check.enough) {
+ val errorMsg = check.errorMessage ?: app.getString(com.droiddeck.launcher.R.string.stores_storage_insufficient, formatBytes(check.requiredBytes), formatBytes(check.availableBytes))
+ val folder = StoreInstallRoot.folderFor(app, item.store, item.id, item.title, target)
+ val entry = com.droiddeck.launcher.stores.download.DownloadEntry(
+ item.store,
+ item.id,
+ item.title,
+ cover = item.imageUrl,
+ bytesTotal = item.sizeBytes,
+ location = StoreInstallRoot.labelFor(app, folder)
+ )
+ com.droiddeck.launcher.stores.download.SavedQueue.remember(app, item, target)
+ com.droiddeck.launcher.stores.download.DownloadQueue.recordFailed(app, entry, errorMsg) {
+ object : com.droiddeck.launcher.stores.download.DownloadQueue.DownloadJob {
+ override fun run(handle: com.droiddeck.launcher.stores.download.DownloadQueue.JobHandle): String? {
+ install(app, item, target)
+ return null
+ }
+ override fun cancel(deleteFiles: Boolean) {}
+ }
+ }
+ return
+ }
com.droiddeck.launcher.stores.download.SavedQueue.remember(app, item, target)
backends[item.store]?.install(app, item, target) ?: logLine("${item.store.label}: this build cannot install yet")
}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt b/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt
index 25fd8aa5..4039f0d6 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/download/DownloadQueue.kt
@@ -185,6 +185,36 @@ object DownloadQueue {
advance(app)
}
+ /** Immediately records a failed download (e.g. from pre-flight storage check). */
+ fun recordFailed(context: Context, entry: DownloadEntry, error: String, factory: (() -> DownloadJob)? = null) {
+ val app = context.applicationContext
+ val now = System.currentTimeMillis()
+ synchronized(lock) {
+ items.remove(entry.key)
+ items[entry.key] = Item(
+ entry.copy(
+ state = DownloadState.FAILED,
+ error = error,
+ speedBps = 0,
+ etaSeconds = -1,
+ startedAt = now,
+ finishedAt = now
+ ),
+ factory ?: { object : DownloadJob {
+ override fun run(handle: JobHandle): String? = null
+ override fun cancel(deleteFiles: Boolean) {}
+ } }
+ )
+ renumber()
+ publishLocked()
+ }
+ StoresState.logLine("${entry.store.label}: \"${entry.name}\" failed: $error")
+ main.post {
+ android.widget.Toast.makeText(app, error, android.widget.Toast.LENGTH_LONG).show()
+ }
+ StoreDownloadService.ended(app, entry.copy(state = DownloadState.FAILED, error = error), installed = false, gamesKey = null)
+ }
+
fun pause(key: String) {
val item = synchronized(lock) { items[key] } ?: return
synchronized(lock) {
@@ -376,6 +406,10 @@ object DownloadQueue {
}
DownloadState.FAILED -> {
StoresState.logLine("${item.entry.store.label}: \"${item.entry.name}\" failed: $error")
+ val failMsg = error ?: context.getString(com.droiddeck.launcher.R.string.stores_dl_failed)
+ main.post {
+ android.widget.Toast.makeText(context.applicationContext, failMsg, android.widget.Toast.LENGTH_LONG).show()
+ }
StoreDownloadService.ended(context, item.entry, installed = false, gamesKey = null)
}
DownloadState.CANCELLED -> StoresState.notifyLibraryChanged(context)
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/download/StoreDownloadService.kt b/app/src/main/java/com/droiddeck/launcher/stores/download/StoreDownloadService.kt
index e8c71f24..2bb7c5cd 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/download/StoreDownloadService.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/download/StoreDownloadService.kt
@@ -263,12 +263,16 @@ class StoreDownloadService : Service() {
val app = context.applicationContext
channels(app)
val code = entry.key.hashCode()
- val n = Notification.Builder(app, DONE_CHANNEL)
+ val builder = Notification.Builder(app, DONE_CHANNEL)
.setSmallIcon(if (installed) android.R.drawable.stat_sys_download_done else android.R.drawable.stat_notify_error)
.setContentTitle(app.getString(if (installed) R.string.stores_notification_installed else R.string.stores_notification_failed, entry.name))
.setAutoCancel(true)
.setContentIntent(if (installed && gamesKey != null) openPage(app, gamesKey, code + 10) else openDownloads(app, code + 11))
- .build()
+ if (!installed && !entry.error.isNullOrBlank()) {
+ builder.setContentText(entry.error)
+ builder.style = Notification.BigTextStyle().bigText(entry.error)
+ }
+ val n = builder.build()
runCatching { app.getSystemService(NotificationManager::class.java)?.notify(ID + 1 + (code and 0xFFFF), n) }
}
}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicDownloadManager.java b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicDownloadManager.java
index ad78a04b..09cc369e 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicDownloadManager.java
+++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicDownloadManager.java
@@ -224,13 +224,18 @@ public final class EpicDownloadManager {
boolean javaPool = true;
final int[] pendingIdx = pendingIndices(manifest, pending);
boolean engine = com.droiddeck.launcher.stores.StoresNative.INSTANCE.getAvailable() && pendingIdx != null;
+ String nativeError = null;
if (engine) {
NativeOutcome r = runNativePool(ctx, manifestBytes, pendingIdx, needed, fTotalBytes, installDirPath, cachePath, cdnUrls, cancel, cb,
completedBytes, completedCount, lastSpeedMs, lastSpeedBytes, speedBps);
if (r.started) {
javaPool = false;
if (r.cancelled) return null;
- if (!r.success) { cb.onLog("epic: engine failed: " + r.error); failCount.incrementAndGet(); }
+ if (!r.success) {
+ cb.onLog("epic: engine failed: " + r.error);
+ nativeError = r.error;
+ failCount.incrementAndGet();
+ }
} else cb.onLog("epic: engine not started (" + r.error + "); using the built-in pool");
}
if (javaPool) {
@@ -265,7 +270,12 @@ public final class EpicDownloadManager {
}
}
if (cancel.get()) return null;
- if (failCount.get() > 0) throw new InstallException(failCount.get() + " chunk(s) failed to download");
+ if (failCount.get() > 0) {
+ if (nativeError != null && !nativeError.isEmpty()) {
+ throw new InstallException(nativeError);
+ }
+ throw new InstallException(failCount.get() + " chunk(s) failed to download");
+ }
// The engine's assembler when it starts (it drops each chunk after its last use and the
// cache at the end); the loop below when it does not.
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt
index e77bd70a..2c648697 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt
@@ -52,7 +52,9 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
val id = key.substringAfter(':')
val item = (StoresState.library[store].orEmpty() + StoresState.shelves[store]?.all.orEmpty()).firstOrNull { it.id == id }
val installed = StoresState.installedGame(store, id)
- val download = StoresState.download("${store.id}:$id")?.takeIf { it.isActive }
+ val rawDownload = StoresState.download("${store.id}:$id")
+ val download = rawDownload?.takeIf { it.isActive }
+ val failedDownload = rawDownload?.takeIf { it.state == DownloadState.FAILED }
// Removal asks twice: one stray press of A should not cost a download.
val title = item?.title ?: installed?.sidecar?.title ?: id
Rise(0) {
@@ -79,12 +81,24 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
val state = when {
installed != null -> stringResource(R.string.stores_eyebrow_installed) + " · " + com.droiddeck.launcher.stores.StoreInstallRoot.labelFor(ctx, installed.folder)
download != null && download.location.isNotBlank() -> stringResource(R.string.stores_eyebrow_library) + " · " + download.location
+ failedDownload != null && failedDownload.location.isNotBlank() -> stringResource(R.string.stores_eyebrow_library) + " · " + failedDownload.location
item?.owned == true -> stringResource(R.string.stores_eyebrow_library)
else -> stringResource(R.string.stores_eyebrow_store)
}
val size = (item?.sizeBytes ?: 0L).takeIf { it > 0 }?.let { " · " + formatBytes(it) } ?: ""
Text((state + size).uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.2.sp, color = pal.signal)
Text(title, fontSize = if (narrow) 24.sp else 30.sp, lineHeight = if (narrow) 27.sp else 33.sp, fontWeight = FontWeight.Black, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis)
+ if (!failedDownload?.error.isNullOrBlank()) {
+ Text(
+ failedDownload!!.error!!,
+ fontSize = 13.sp,
+ lineHeight = 17.sp,
+ fontWeight = FontWeight.Medium,
+ color = colors.error,
+ maxLines = 3,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
Actions {
when {
installed != null -> {
@@ -106,6 +120,16 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
else PrimaryButton(stringResource(R.string.stores_dl_resume), main = true) { com.droiddeck.launcher.stores.download.DownloadQueue.resume(ctx, download.key) }
ConfirmButton(stringResource(R.string.stores_dl_cancel), stringResource(R.string.stores_dl_cancel_confirm)) { com.droiddeck.launcher.stores.download.DownloadQueue.cancel(ctx, download.key) }
}
+ failedDownload != null -> {
+ ActionChip(stringResource(R.string.stores_dl_failed), ok = false)
+ PrimaryButton(stringResource(R.string.stores_dl_resume), main = true) {
+ if (item != null) StoresState.requestInstall(ctx, item)
+ else com.droiddeck.launcher.stores.download.DownloadQueue.retry(ctx, failedDownload.key)
+ }
+ ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) {
+ com.droiddeck.launcher.stores.download.DownloadQueue.clear(ctx, failedDownload.key)
+ }
+ }
item?.owned == true && StoresState.isUnfinished(item) -> {
InstallButton(stringResource(R.string.stores_resume_install)) { StoresState.requestInstall(ctx, item) }
ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { StoresState.clearUnfinished(ctx, item) }
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt
index 47b356e7..520d9fee 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDownloads.kt
@@ -339,6 +339,7 @@ private fun Modifier.timeRim(left: () -> Float, color: Color): Modifier = drawWi
@Composable
private fun DownloadCard(d: DownloadEntry, s: FrontEndState, a: FrontEndActions) {
val ctx = LocalContext.current
+ val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val narrow = LocalNarrowPane.current
val ink = Color.White
@@ -362,8 +363,11 @@ private fun DownloadCard(d: DownloadEntry, s: FrontEndState, a: FrontEndActions)
val meta = when (d.state) {
DownloadState.INSTALLED -> stringResource(R.string.stores_installed_chip)
DownloadState.CANCELLED -> stringResource(R.string.stores_dl_cancelled)
- // The reason is in the engine log; the row only says what is kept for Resume.
- DownloadState.FAILED -> if (d.bytesDone > 0) stringResource(R.string.stores_dl_failed_kept, formatBytes(d.bytesDone)) else stringResource(R.string.stores_dl_failed)
+ DownloadState.FAILED -> {
+ if (!d.error.isNullOrBlank()) stringResource(R.string.stores_dl_failed_reason, d.error)
+ else if (d.bytesDone > 0) stringResource(R.string.stores_dl_failed_kept, formatBytes(d.bytesDone))
+ else stringResource(R.string.stores_dl_failed)
+ }
DownloadState.PAUSED -> stringResource(R.string.stores_dl_paused)
DownloadState.QUEUED -> if (d.queuePosition > 0) stringResource(R.string.stores_dl_queued_at, d.queuePosition) else stringResource(R.string.stores_dl_queued)
DownloadState.RUNNING -> {
@@ -375,7 +379,8 @@ private fun DownloadCard(d: DownloadEntry, s: FrontEndState, a: FrontEndActions)
}
}
}
- Text(meta, fontSize = 12.sp, color = dim, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
+ val metaColor = if (d.state == DownloadState.FAILED) colors.error else dim
+ Text(meta, fontSize = 12.sp, color = metaColor, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
if (d.state == DownloadState.RUNNING && (d.stage == DownloadStage.DOWNLOAD || d.stage == DownloadStage.INSTALL) && d.speedBps > 0) {
Text(formatSpeed(d.speedBps), fontSize = 12.sp, color = dim, maxLines = 1)
if (d.etaSeconds >= 0) Text(eta(d.etaSeconds), fontSize = 12.sp, color = dim, maxLines = 1)
@@ -393,7 +398,10 @@ private fun DownloadCard(d: DownloadEntry, s: FrontEndState, a: FrontEndActions)
RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_dl_resume), primary = true) { DownloadQueue.resume(ctx, d.key) }
CancelAction { DownloadQueue.cancel(ctx, d.key) }
}
- DownloadState.FAILED -> RoundAction(Icons.Filled.Refresh, stringResource(R.string.stores_dl_resume), primary = true) { DownloadQueue.retry(ctx, d.key) }
+ DownloadState.FAILED -> {
+ RoundAction(Icons.Filled.Refresh, stringResource(R.string.stores_dl_resume), primary = true) { DownloadQueue.retry(ctx, d.key) }
+ CancelAction { DownloadQueue.clear(ctx, d.key) }
+ }
// Just installed (this row is on its way under the line): Play blooms where the bar gathered.
DownloadState.INSTALLED -> Box(Modifier.bloomIn(true, 420)) {
RoundAction(Icons.Filled.PlayArrow, stringResource(R.string.stores_play), primary = true) { launchStoreGame(ctx, d.store, d.id, s, a) }
@@ -460,6 +468,7 @@ private fun StageBar(d: DownloadEntry) {
val x = i * (segW + gap)
drawRoundRect(colors.surfaceVariant, Offset(x, cy - h / 2f), Size(segW, h), CornerRadius(h / 2f))
val base = when {
+ d.state == DownloadState.FAILED -> colors.error
done[i] -> pal.good
actives[i] && paused -> pausedColor.copy(alpha = breath)
actives[i] && stages[i] == DownloadStage.VERIFY -> verifyColor
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresInstallDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresInstallDialog.kt
index e7d645a0..4cc53ae2 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/StoresInstallDialog.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresInstallDialog.kt
@@ -72,10 +72,18 @@ internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) {
Rise(1) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
targets.forEachIndexed { i, t ->
+ val needed = com.droiddeck.launcher.stores.StoreInstallRoot.estimatedBytesNeeded(item, t.root, !t.removable)
+ val isLow = needed > 0 && t.freeBytes in 1 until needed
+ val freeText = if (isLow) {
+ stringResource(R.string.stores_storage_low, formatBytes(t.freeBytes), formatBytes(needed))
+ } else {
+ stringResource(R.string.stores_target_free, formatBytes(t.freeBytes))
+ }
TargetRow(
label = if (t.removable) t.label else stringResource(R.string.stores_target_internal),
- free = stringResource(R.string.stores_target_free, formatBytes(t.freeBytes)),
+ free = freeText,
removable = t.removable, focus = focus[i],
+ warning = isLow,
) {
SessionPrefs.setStoresInstallTarget(ctx, t.root.absolutePath)
StoresState.install(ctx, item, t.root)
@@ -88,7 +96,7 @@ internal fun InstallWhereDialog(item: CatalogItem, onDismiss: () -> Unit) {
}
@Composable
-private fun TargetRow(label: String, free: String, removable: Boolean, focus: FocusRequester, onPick: () -> Unit) {
+private fun TargetRow(label: String, free: String, removable: Boolean, focus: FocusRequester, warning: Boolean = false, onPick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
@@ -104,6 +112,6 @@ private fun TargetRow(label: String, free: String, removable: Boolean, focus: Fo
) {
Icon(if (removable) Icons.Outlined.SdCard else Icons.Outlined.Smartphone, null, tint = if (hot) pal.signal else colors.onBackground, modifier = Modifier.size(22.dp))
Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
- Text(free, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1)
+ Text(free, fontSize = 12.sp, color = if (warning) colors.error else colors.onSurfaceVariant, maxLines = 1)
}
}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt
index 1d722dd3..2494514e 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt
@@ -511,10 +511,17 @@ private fun InstallWhere(item: CatalogItem) {
StepTitle(title)
StepList {
targets.forEachIndexed { i, t ->
+ val needed = com.droiddeck.launcher.stores.StoreInstallRoot.estimatedBytesNeeded(item, t.root, !t.removable)
+ val isLow = needed > 0 && t.freeBytes in 1 until needed
+ val trailingText = if (isLow) {
+ stringResource(R.string.stores_storage_low, formatBytes(t.freeBytes), formatBytes(needed))
+ } else {
+ stringResource(R.string.stores_target_free, formatBytes(t.freeBytes))
+ }
StepChoice(
label = if (t.removable) t.label else internalLabel,
icon = if (t.removable) androidx.compose.material.icons.Icons.Outlined.SdCard else androidx.compose.material.icons.Icons.Outlined.Smartphone,
- trailing = stringResource(R.string.stores_target_free, formatBytes(t.freeBytes)),
+ trailing = trailingText,
enabled = open,
modifier = Modifier.fillMaxWidth().stepItem(1 + i).then(if (i == defaultIndex) Modifier.focusRequester(first) else Modifier),
) {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 3415cc58..dd47a988 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2257,6 +2257,9 @@
Install %1$s:
Internal
%1$s free
+ Not enough free space: need %1$s, only %2$s free
+ Not enough internal space for download cache: need %1$s, only %2$s free
+ Insufficient space (%1$s free, need %2$s)
Store downloads
Shows game download progress and keeps downloads running in the background
Downloading
diff --git a/app/src/test/java/com/droiddeck/launcher/stores/StoreInstallRootTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/StoreInstallRootTest.kt
index 3856ed56..ae1353e7 100644
--- a/app/src/test/java/com/droiddeck/launcher/stores/StoreInstallRootTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/stores/StoreInstallRootTest.kt
@@ -59,4 +59,22 @@ class StoreInstallRootTest {
assertTrue(dir.absolutePath.startsWith(app.cacheDir.absolutePath))
assertEquals("a_b_c", dir.name)
}
+
+ @Test fun estimatedBytesNeededCountsDoubleForEpicInternalStorage() {
+ val item = CatalogItem(Store.EPIC, "game1", "Game 1", imageUrl = null, owned = true, sizeBytes = 10_000_000L)
+ val internalDir = StoreInstallRoot.internalRoot(app)
+ val cardDir = File(app.filesDir, "card/Games")
+
+ assertEquals(20_000_000L, StoreInstallRoot.estimatedBytesNeeded(item, internalDir, isInternal = true))
+ assertEquals(10_000_000L, StoreInstallRoot.estimatedBytesNeeded(item, cardDir, isInternal = false))
+
+ val gogItem = CatalogItem(Store.GOG, "game2", "Game 2", imageUrl = null, owned = true, sizeBytes = 10_000_000L)
+ assertEquals(10_000_000L, StoreInstallRoot.estimatedBytesNeeded(gogItem, internalDir, isInternal = true))
+ }
+
+ @Test fun checkStorageReportsEnoughWhenSpaceIsUnknownOrAvailable() {
+ val zeroItem = CatalogItem(Store.EPIC, "game1", "Game 1", imageUrl = null, owned = true, sizeBytes = 0L)
+ val check = StoreInstallRoot.checkStorage(app, zeroItem, StoreInstallRoot.internalRoot(app))
+ assertTrue(check.enough)
+ }
}