Show storage warning and error message on insufficient download space

This commit is contained in:
Juan Marti Mercado committed 2026-10-13 19:15:31 -04:00
1 parent 4163b31b02
commit 82b8cf1230
11 files changed
+228 -14

No files matched your search

@@ -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. */
@@ -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")
}
@@ -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)
@@ -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) }
}
}
@@ -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.
@@ -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) }
@@ -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
@@ -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)
}
}
@@ -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),
) {
+3
View File
@@ -2257,6 +2257,9 @@
<string name="stores_install_title">Install %1$s:</string>
<string name="stores_target_internal">Internal</string>
<string name="stores_target_free">%1$s free</string>
<string name="stores_storage_insufficient">Not enough free space: need %1$s, only %2$s free</string>
<string name="stores_storage_internal_cache_insufficient">Not enough internal space for download cache: need %1$s, only %2$s free</string>
<string name="stores_storage_low">Insufficient space (%1$s free, need %2$s)</string>
<string name="stores_notification_channel">Store downloads</string>
<string name="stores_notification_channel_hint">Shows game download progress and keeps downloads running in the background</string>
<string name="stores_notification_one">Downloading</string>
@@ -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)
}
}