diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 1dbfcd5b3..cc9fd6688 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -69,6 +69,14 @@
+
+
+
+
+
diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
index 0fc89e5ad..9dc4e7494 100644
--- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt
@@ -122,6 +122,7 @@ import com.droiddeck.launcher.ui.SessionPausedOverlay
import com.droiddeck.launcher.ui.SecondScreenPresentation
import com.droiddeck.launcher.ui.BottomDashboard
import com.droiddeck.launcher.ui.BottomPage
+import com.droiddeck.launcher.ui.sessionBottomPages
import com.droiddeck.launcher.ui.DroidDeckTheme
import com.droiddeck.launcher.wayland.CompositorHost
import com.droiddeck.launcher.wayland.WaylandCompositor
@@ -3132,7 +3133,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
)
/** Pages for the bottom-screen dashboard; the dashboard is offered only while at least one exists. */
- private fun bottomPages(): List = emptyList()
+ private fun bottomPages(): List = sessionBottomPages()
private fun tapSteamButton() {
if (SessionState.mode == SessionService.MODE_STEAM) {
diff --git a/app/src/main/java/com/droiddeck/launcher/session/BottomNotifications.kt b/app/src/main/java/com/droiddeck/launcher/session/BottomNotifications.kt
new file mode 100644
index 000000000..8e41ee1fd
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/session/BottomNotifications.kt
@@ -0,0 +1,62 @@
+package com.droiddeck.launcher.session
+
+import android.app.Notification
+import android.content.Context
+import android.service.notification.NotificationListenerService
+import android.service.notification.StatusBarNotification
+import androidx.compose.runtime.mutableStateListOf
+import androidx.core.app.NotificationManagerCompat
+
+/** One notification for the bottom-screen list. Kept in memory only; never written to disk or logs. */
+data class BottomNotification(val key: String, val app: String, val title: String, val text: String, val postedAt: Long)
+
+/**
+ * Notifications from other apps for the bottom screen, so a message can be read without leaving the game.
+ * Works only after the user allows DroidDeck notification access in Android settings.
+ */
+object BottomNotifications {
+ val items = mutableStateListOf()
+
+ fun granted(context: Context): Boolean =
+ context.packageName in NotificationManagerCompat.getEnabledListenerPackages(context)
+
+ internal fun keep(own: String, pkg: String, ongoing: Boolean, groupSummary: Boolean, title: String, text: String): Boolean =
+ pkg != own && !ongoing && !groupSummary && (title.isNotBlank() || text.isNotBlank())
+
+ internal fun add(list: MutableList, item: BottomNotification) {
+ list.removeAll { it.key == item.key }
+ list.add(0, item)
+ while (list.size > MAX_ITEMS) list.removeAt(list.lastIndex)
+ }
+
+ fun clear() = items.clear()
+
+ internal fun post(context: Context, sbn: StatusBarNotification) {
+ val n = sbn.notification ?: return
+ val extras = n.extras
+ val title = extras?.getCharSequence(Notification.EXTRA_TITLE)?.toString().orEmpty()
+ val text = (extras?.getCharSequence(Notification.EXTRA_BIG_TEXT) ?: extras?.getCharSequence(Notification.EXTRA_TEXT))?.toString().orEmpty()
+ if (!keep(context.packageName, sbn.packageName, sbn.isOngoing, (n.flags and Notification.FLAG_GROUP_SUMMARY) != 0, title, text)) return
+ val app = runCatching {
+ val pm = context.packageManager
+ pm.getApplicationLabel(pm.getApplicationInfo(sbn.packageName, 0)).toString()
+ }.getOrDefault(sbn.packageName)
+ add(items, BottomNotification(sbn.key, app, title, text, sbn.postTime))
+ }
+
+ internal fun remove(key: String) {
+ items.removeAll { it.key == key }
+ }
+
+ private const val MAX_ITEMS = 30
+}
+
+class BottomNotificationListener : NotificationListenerService() {
+ override fun onListenerConnected() {
+ runCatching { activeNotifications }.getOrNull()?.sortedBy { it.postTime }?.forEach { BottomNotifications.post(this, it) }
+ }
+
+ override fun onNotificationPosted(sbn: StatusBarNotification) = BottomNotifications.post(this, sbn)
+
+ override fun onNotificationRemoved(sbn: StatusBarNotification) = BottomNotifications.remove(sbn.key)
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
index fee992446..3a34404c2 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionService.kt
@@ -73,7 +73,9 @@ class SessionService : Service() {
private var downloadMonitorTask: Runnable? = null
private var downloadActive = false
private var clientInstallActive = false
- @Volatile private var steamLibraryMounts = emptyMap()
+ private var steamLibraryMounts: Map
+ get() = SessionState.steamLibraryMounts
+ set(value) { SessionState.steamLibraryMounts = value }
private var suspendDownloadNow = false
private var suspendPolicy = SessionPrefs.SUSPEND_MANUAL
private var steamDownloadsInBackground = false
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt
index 0d19d49bc..3cf93a322 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SessionState.kt
@@ -94,6 +94,9 @@ object SessionState {
@Volatile var secondScreenAutoOpened = false
@Volatile var secondScreenProvisional = false
+ /** Guest library paths on removable storage and the host folders they are mounted from, set by SessionService. */
+ @Volatile var steamLibraryMounts: Map = emptyMap()
+
@Volatile
var mode = "steam"
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamDownloadMonitor.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamDownloadMonitor.kt
index ff3602c1b..ccc8693c3 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/SteamDownloadMonitor.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDownloadMonitor.kt
@@ -101,34 +101,6 @@ internal class SteamDownloadMonitor {
now - lastContentActivityAt <= CONTENT_ACTIVITY_GRACE_MS
}
- private fun manifests(runtimeRoot: File, mounts: Map): List {
- val steamRoots = linkedSetOf()
- for (home in STEAM_HOMES) {
- val primary = File(runtimeRoot, home)
- steamRoots += primary
- val libraryFolders = File(primary, "steamapps/libraryfolders.vdf")
- runCatching {
- PATH.findAll(libraryFolders.readText()).forEach { match ->
- steamRoots += hostPath(runtimeRoot, match.groupValues[1], mounts)
- }
- }
- }
- return steamRoots.flatMap { root ->
- File(root, "steamapps").listFiles { file ->
- file.isFile && file.name.startsWith("appmanifest_") && file.name.endsWith(".acf")
- }.orEmpty().toList()
- }
- }
-
- private fun hostPath(runtimeRoot: File, guest: String, mounts: Map): File {
- val path = guest.trimEnd('/')
- for ((mount, host) in mounts) {
- if (path == mount) return host
- if (path.startsWith("$mount/")) return File(host, path.removePrefix("$mount/"))
- }
- return File(runtimeRoot, path.removePrefix("/"))
- }
-
private fun read(file: File): ManifestState {
val text = runCatching { file.readText() }.getOrDefault("")
val flags = value(text, "StateFlags")?.toLongOrNull() ?: 0L
@@ -137,9 +109,6 @@ internal class SteamDownloadMonitor {
return ManifestState(downloaded, flags != 4L && (total <= 0L || downloaded < total))
}
- private fun value(text: String, key: String): String? =
- Regex("\"$key\"\\s+\"([^\"]*)\"").find(text)?.groupValues?.get(1)
-
companion object {
fun clientInstallPending(runtimeRoot: File): Boolean {
val home = File(runtimeRoot, "root")
@@ -149,12 +118,43 @@ internal class SteamDownloadMonitor {
return !(stamped && File(steam, "steamrtarm64/steam").isFile) || File(home, ".bl-steam-repair").exists()
}
+ internal fun manifests(runtimeRoot: File, mounts: Map): List {
+ val steamRoots = linkedSetOf()
+ for (home in STEAM_HOMES) {
+ val primary = File(runtimeRoot, home)
+ steamRoots += primary
+ val libraryFolders = File(primary, "steamapps/libraryfolders.vdf")
+ runCatching {
+ PATH.findAll(libraryFolders.readText()).forEach { match ->
+ steamRoots += hostPath(runtimeRoot, match.groupValues[1], mounts)
+ }
+ }
+ }
+ return steamRoots.flatMap { root ->
+ File(root, "steamapps").listFiles { file ->
+ file.isFile && file.name.startsWith("appmanifest_") && file.name.endsWith(".acf")
+ }.orEmpty().toList()
+ }
+ }
+
+ private fun hostPath(runtimeRoot: File, guest: String, mounts: Map): File {
+ val path = guest.trimEnd('/')
+ for ((mount, host) in mounts) {
+ if (path == mount) return host
+ if (path.startsWith("$mount/")) return File(host, path.removePrefix("$mount/"))
+ }
+ return File(runtimeRoot, path.removePrefix("/"))
+ }
+
+ internal fun value(text: String, key: String): String? =
+ Regex("\"$key\"\\s+\"([^\"]*)\"").find(text)?.groupValues?.get(1)
+
private const val MANIFEST_PROGRESS_GRACE_MS = 8_000L
private const val CONTENT_ACTIVITY_GRACE_MS = 90_000L
private const val MIN_RECEIVE_BYTES_PER_SEC = 64L * 1024L
private val PATH = Regex("\"path\"\\s+\"([^\"]+)\"")
- private val STEAM_HOMES = listOf("root/.local/share/Steam", "root/.droiddeck-x64/.local/share/Steam")
- private val CONTENT_UPDATE = Regex("""AppID (\d+) (App|Workshop|Shader) update changed : (.*)""")
+ internal val STEAM_HOMES = listOf("root/.local/share/Steam", "root/.droiddeck-x64/.local/share/Steam")
+ internal val CONTENT_UPDATE = Regex("""AppID (\d+) (App|Workshop|Shader) update changed : (.*)""")
private val CURRENT_RATE = Regex("""Current download rate: ([\d.]+) Mbps""")
}
}
diff --git a/app/src/main/java/com/droiddeck/launcher/session/SteamDownloads.kt b/app/src/main/java/com/droiddeck/launcher/session/SteamDownloads.kt
new file mode 100644
index 000000000..fd4be89cb
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/session/SteamDownloads.kt
@@ -0,0 +1,72 @@
+package com.droiddeck.launcher.session
+
+import java.io.File
+import java.io.RandomAccessFile
+
+/** A Steam download or update the client is working on. [total] is 0 while Steam has not sized it yet. */
+data class SteamDownload(
+ val appId: String,
+ val name: String,
+ val done: Long,
+ val total: Long,
+ val shader: Boolean = false,
+ val paused: Boolean = false,
+)
+
+/** Reads Steam's own appmanifests and content log, the same files [SteamDownloadMonitor] watches. */
+object SteamDownloads {
+ fun read(runtimeRoot: File, mounts: Map = emptyMap(), client: String? = null): List {
+ val names = HashMap()
+ val apps = SteamDownloadMonitor.manifests(runtimeRoot, mounts).mapNotNull { file ->
+ val text = runCatching { file.readText() }.getOrNull() ?: return@mapNotNull null
+ val id = SteamDownloadMonitor.value(text, "appid") ?: file.name.removePrefix("appmanifest_").removeSuffix(".acf")
+ val name = SteamDownloadMonitor.value(text, "name") ?: id
+ names[id] = name
+ fromManifest(id, name, text)
+ }
+ val homes = client?.let { listOf(if (it == "x86_64") SteamDownloadMonitor.STEAM_HOMES[1] else SteamDownloadMonitor.STEAM_HOMES[0]) }
+ ?: SteamDownloadMonitor.STEAM_HOMES
+ val log = homes.map { File(runtimeRoot, "$it/logs/content_log.txt") }.filter { it.isFile }.maxByOrNull { it.lastModified() }
+ val shaders = log?.let { runningShaderUpdates(tail(it)) }.orEmpty()
+ .map { SteamDownload(it, names[it] ?: it, 0L, 0L, shader = true) }
+ return apps + shaders
+ }
+
+ internal fun fromManifest(id: String, name: String, text: String): SteamDownload? {
+ val flags = SteamDownloadMonitor.value(text, "StateFlags")?.toLongOrNull() ?: 0L
+ if (flags == FULLY_INSTALLED) return null
+ val done = SteamDownloadMonitor.value(text, "BytesDownloaded")?.toLongOrNull() ?: 0L
+ val total = SteamDownloadMonitor.value(text, "BytesToDownload")?.toLongOrNull() ?: 0L
+ if (total > 0L && done >= total) return null
+ if ((flags and UPDATE_REQUIRED) == 0L && total <= 0L) return null
+ return SteamDownload(id, name, done, total, paused = (flags and UPDATE_PAUSED) != 0L)
+ }
+
+ /** App ids whose latest shader-cache line in the content log says the update is still running. */
+ internal fun runningShaderUpdates(lines: Sequence): List {
+ val state = LinkedHashMap()
+ for (line in lines) {
+ if ("Client version:" in line) state.clear()
+ val match = SteamDownloadMonitor.CONTENT_UPDATE.find(line) ?: continue
+ if (match.groupValues[2] != "Shader") continue
+ val running = match.groupValues[3].let { "Running Update" in it && "Stopping" !in it }
+ state[match.groupValues[1]] = running
+ }
+ return state.filterValues { it }.keys.toList()
+ }
+
+ private fun tail(file: File): Sequence = runCatching {
+ RandomAccessFile(file, "r").use { raf ->
+ val start = (raf.length() - TAIL_BYTES).coerceAtLeast(0L)
+ raf.seek(start)
+ val bytes = ByteArray((raf.length() - start).toInt())
+ raf.readFully(bytes)
+ String(bytes, Charsets.ISO_8859_1).lineSequence().drop(if (start > 0L) 1 else 0).toList().asSequence()
+ }
+ }.getOrDefault(emptySequence())
+
+ private const val FULLY_INSTALLED = 4L
+ private const val UPDATE_REQUIRED = 2L
+ private const val UPDATE_PAUSED = 512L
+ private const val TAIL_BYTES = 256L * 1024L
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BottomDownloadsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomDownloadsPage.kt
new file mode 100644
index 000000000..ffd828c6b
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomDownloadsPage.kt
@@ -0,0 +1,126 @@
+package com.droiddeck.launcher.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.droiddeck.launcher.R
+import com.droiddeck.launcher.runtime.LinuxRuntime
+import com.droiddeck.launcher.session.SessionState
+import com.droiddeck.launcher.session.SteamDownload
+import com.droiddeck.launcher.session.SteamDownloads
+import com.droiddeck.launcher.stores.StoresState
+import com.droiddeck.launcher.stores.download.DownloadState
+import com.droiddeck.launcher.stores.formatBytes
+import com.droiddeck.launcher.stores.formatSpeed
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
+
+/** Bytes per second between two reads of the same download; 0 when it did not move or restarted. */
+internal fun downloadRate(previousDone: Long, previousAt: Long, done: Long, at: Long): Long =
+ if (previousDone < 0L || done <= previousDone || at <= previousAt) 0L else (done - previousDone) * 1000L / (at - previousAt)
+
+private const val STEAM_POLL_MS = 2_000L
+
+private class SteamRow(val download: SteamDownload, val rate: Long)
+
+@Composable
+fun BottomDownloadsPage() {
+ val context = LocalContext.current
+ val colors = MaterialTheme.colorScheme
+ var steam by remember { mutableStateOf>(emptyList()) }
+ LaunchedEffect(Unit) {
+ var last = emptyMap>()
+ while (true) {
+ val now = System.currentTimeMillis()
+ val read = withContext(Dispatchers.IO) {
+ runCatching {
+ SteamDownloads.read(LinuxRuntime.rootDir(context), SessionState.steamLibraryMounts, SessionState.steamClientArch)
+ }.getOrDefault(emptyList())
+ }
+ steam = read.map { d ->
+ val before = last[d.appId]
+ SteamRow(d, if (before == null) 0L else downloadRate(before.first, before.second, d.done, now))
+ }
+ last = read.associate { it.appId to (it.done to now) }
+ delay(STEAM_POLL_MS)
+ }
+ }
+ val store = StoresState.downloads.filter { it.isActive }
+ if (steam.isEmpty() && store.isEmpty()) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(stringResource(R.string.bottom_downloads_none), fontSize = 15.sp, color = colors.onSurfaceVariant)
+ }
+ return
+ }
+ LazyColumn(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ items(steam, key = { "steam:${it.download.appId}:${it.download.shader}" }) { row ->
+ val d = row.download
+ val detail = when {
+ d.shader -> stringResource(R.string.bottom_downloads_shader)
+ d.paused -> stringResource(R.string.bottom_downloads_paused)
+ d.total <= 0L -> stringResource(R.string.bottom_downloads_queued)
+ else -> listOf("${formatBytes(d.done)} / ${formatBytes(d.total)}", formatSpeed(row.rate)).filter { it.isNotEmpty() }.joinToString(" · ")
+ }
+ DownloadRow(d.name, stringResource(R.string.bottom_downloads_steam), detail,
+ if (d.total > 0L) (d.done.toFloat() / d.total).coerceIn(0f, 1f) else null)
+ }
+ items(store, key = { "store:${it.key}" }) { entry ->
+ val e = StoresState.download(entry.key) ?: entry
+ val detail = when (e.state) {
+ DownloadState.PAUSED -> stringResource(R.string.bottom_downloads_paused)
+ DownloadState.QUEUED -> stringResource(R.string.bottom_downloads_queued)
+ else -> listOf(
+ if (e.bytesTotal > 0L) "${formatBytes(e.bytesDone)} / ${formatBytes(e.bytesTotal)}" else "",
+ formatSpeed(e.speedBps),
+ ).filter { it.isNotEmpty() }.joinToString(" · ")
+ }
+ DownloadRow(e.name, e.store.label, detail, e.stageFraction.takeIf { it >= 0f })
+ }
+ }
+}
+
+@Composable
+private fun DownloadRow(name: String, source: String, detail: String, fraction: Float?) {
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ Column(
+ Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(pal.surface).padding(horizontal = 14.dp, vertical = 10.dp),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(name, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
+ maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
+ Text(source, fontSize = 12.sp, color = colors.onSurfaceVariant, maxLines = 1, modifier = Modifier.padding(start = 8.dp))
+ }
+ if (fraction != null) LinearProgressIndicator(progress = { fraction }, modifier = Modifier.fillMaxWidth(), color = pal.signal)
+ else LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), color = pal.signal)
+ if (detail.isNotEmpty()) Text(detail, fontSize = 13.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BottomNotificationsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomNotificationsPage.kt
new file mode 100644
index 000000000..268311481
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomNotificationsPage.kt
@@ -0,0 +1,108 @@
+package com.droiddeck.launcher.ui
+
+import android.content.Intent
+import android.provider.Settings
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.droiddeck.launcher.R
+import com.droiddeck.launcher.session.BottomNotifications
+import kotlinx.coroutines.delay
+
+@Composable
+fun BottomNotificationsPage() {
+ val context = LocalContext.current
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ var granted by remember { mutableStateOf(BottomNotifications.granted(context)) }
+ LaunchedEffect(Unit) {
+ while (true) {
+ granted = BottomNotifications.granted(context)
+ delay(2_000L)
+ }
+ }
+ if (!granted) {
+ Column(Modifier.fillMaxSize().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically)) {
+ Text(stringResource(R.string.bottom_notifications_why), fontSize = 15.sp, color = colors.onBackground, textAlign = TextAlign.Center)
+ BottomPillButton(stringResource(R.string.bottom_notifications_allow), "bottom-notifications-allow") {
+ runCatching {
+ context.startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ }
+ }
+ }
+ return
+ }
+ val items = BottomNotifications.items
+ if (items.isEmpty()) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(stringResource(R.string.bottom_notifications_none), fontSize = 15.sp, color = colors.onSurfaceVariant)
+ }
+ return
+ }
+ val timeFormat = remember { android.text.format.DateFormat.getTimeFormat(context) }
+ Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
+ Text(stringResource(R.string.bottom_notifications_count, items.size), fontSize = 13.sp, color = colors.onSurfaceVariant,
+ modifier = Modifier.weight(1f))
+ BottomPillButton(stringResource(R.string.bottom_notifications_clear), "bottom-notifications-clear") { BottomNotifications.clear() }
+ }
+ LazyColumn(Modifier.weight(1f).fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ items(items.toList(), key = { it.key }) { n ->
+ Column(Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(pal.surface).padding(horizontal = 14.dp, vertical = 10.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(n.app, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1,
+ overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
+ Text(timeFormat.format(java.util.Date(n.postedAt)), fontSize = 12.sp, color = colors.onSurfaceVariant)
+ }
+ if (n.title.isNotBlank()) Text(n.title, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground,
+ maxLines = 1, overflow = TextOverflow.Ellipsis)
+ if (n.text.isNotBlank()) Text(n.text, fontSize = 14.sp, color = colors.onBackground, maxLines = 4, overflow = TextOverflow.Ellipsis)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+internal fun BottomPillButton(label: String, tag: String, onClick: () -> Unit) {
+ val pal = LocalPalette.current
+ Box(
+ Modifier.height(40.dp).clip(RoundedCornerShape(20.dp)).background(pal.signal.copy(alpha = 0.16f))
+ .clickable(role = Role.Button, onClick = onClick).testTag(tag).padding(horizontal = 16.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1)
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BottomPages.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomPages.kt
new file mode 100644
index 000000000..85e33d52e
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomPages.kt
@@ -0,0 +1,12 @@
+package com.droiddeck.launcher.ui
+
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Download
+import androidx.compose.material.icons.outlined.Notifications
+import com.droiddeck.launcher.R
+
+/** The pages the bottom-screen dashboard offers during a session, in tab order. */
+fun sessionBottomPages(): List = listOf(
+ BottomPage("downloads", R.string.bottom_page_downloads, Icons.Outlined.Download) { BottomDownloadsPage() },
+ BottomPage("notifications", R.string.bottom_page_notifications, Icons.Outlined.Notifications) { BottomNotificationsPage() },
+)
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 680dda917..ce976356f 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1877,6 +1877,19 @@
Deck grips + trackpads
Dashboard
Nothing to show here yet.
+ Downloads
+ No downloads running.
+ Steam
+ Downloading shader cache
+ Paused
+ Waiting to start
+ Notifications
+ DroidDeck bottom screen
+ Show messages and other notifications here while you play. Android asks you once to allow notification access for DroidDeck. They stay on this screen only and are not saved.
+ Allow notification access
+ No notifications.
+ %1$d notifications
+ Clear
Removing Linux runtime
diff --git a/app/src/test/java/com/droiddeck/launcher/session/BottomNotificationsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/BottomNotificationsTest.kt
new file mode 100644
index 000000000..19046dc27
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/session/BottomNotificationsTest.kt
@@ -0,0 +1,38 @@
+package com.droiddeck.launcher.session
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class BottomNotificationsTest {
+ private val own = "com.droiddeck.launcher"
+
+ @Test fun messagesFromOtherAppsAreKept() {
+ assertTrue(BottomNotifications.keep(own, "com.discord", ongoing = false, groupSummary = false, title = "Kurt", text = "ready?"))
+ }
+
+ @Test fun ownOngoingSummaryAndEmptyNotificationsAreSkipped() {
+ assertFalse(BottomNotifications.keep(own, own, ongoing = false, groupSummary = false, title = "Session", text = "running"))
+ assertFalse(BottomNotifications.keep(own, "com.spotify", ongoing = true, groupSummary = false, title = "Song", text = "Artist"))
+ assertFalse(BottomNotifications.keep(own, "com.discord", ongoing = false, groupSummary = true, title = "2 messages", text = ""))
+ assertFalse(BottomNotifications.keep(own, "com.discord", ongoing = false, groupSummary = false, title = " ", text = ""))
+ }
+
+ @Test fun anUpdatedNotificationMovesToTheTopOnce() {
+ val list = mutableListOf(
+ BottomNotification("a", "App", "one", "", 1L),
+ BottomNotification("b", "App", "two", "", 2L),
+ )
+ BottomNotifications.add(list, BottomNotification("b", "App", "two again", "", 3L))
+ assertEquals(listOf("b", "a"), list.map { it.key })
+ assertEquals("two again", list.first().title)
+ }
+
+ @Test fun theListKeepsTheNewestThirty() {
+ val list = mutableListOf()
+ repeat(35) { BottomNotifications.add(list, BottomNotification("k$it", "App", "t$it", "", it.toLong())) }
+ assertEquals(30, list.size)
+ assertEquals("k34", list.first().key)
+ }
+}
diff --git a/app/src/test/java/com/droiddeck/launcher/session/SteamDownloadsTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SteamDownloadsTest.kt
new file mode 100644
index 000000000..c0f2baead
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/session/SteamDownloadsTest.kt
@@ -0,0 +1,55 @@
+package com.droiddeck.launcher.session
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class SteamDownloadsTest {
+ private fun manifest(flags: Long, done: Long, total: Long) = """
+ "AppState"
+ {
+ "appid" "220"
+ "name" "Half-Life 2"
+ "StateFlags" "$flags"
+ "BytesToDownload" "$total"
+ "BytesDownloaded" "$done"
+ }
+ """.trimIndent()
+
+ @Test fun anInstalledGameIsNotADownload() {
+ assertNull(SteamDownloads.fromManifest("220", "Half-Life 2", manifest(4, 100, 100)))
+ }
+
+ @Test fun aRunningUpdateShowsItsBytes() {
+ val d = SteamDownloads.fromManifest("220", "Half-Life 2", manifest(1026, 40, 100))!!
+ assertEquals(40L, d.done)
+ assertEquals(100L, d.total)
+ }
+
+ @Test fun aPausedUpdateSaysSo() {
+ assertTrue(SteamDownloads.fromManifest("220", "Half-Life 2", manifest(514, 40, 100))!!.paused)
+ }
+
+ @Test fun anUpdateNotSizedYetIsStillListed() {
+ assertEquals(0L, SteamDownloads.fromManifest("220", "Half-Life 2", manifest(6, 0, 0))!!.total)
+ }
+
+ @Test fun onlyShaderUpdatesStillRunningCount() {
+ val log = sequenceOf(
+ "[2026-10-30 06:33:02] AppID 220 Shader update changed : Running Update,Downloading,",
+ "[2026-10-30 06:33:02] AppID 440 Shader update changed : Running Update,",
+ "[2026-10-30 06:33:03] AppID 440 Shader update changed : None",
+ "[2026-10-30 06:33:03] AppID 570 App update changed : Running Update,",
+ )
+ assertEquals(listOf("220"), SteamDownloads.runningShaderUpdates(log))
+ }
+
+ @Test fun aClientRestartForgetsOlderShaderUpdates() {
+ val log = sequenceOf(
+ "[2026-10-30 06:33:02] AppID 220 Shader update changed : Running Update,",
+ "[2026-10-30 07:00:00] Client version: 1761000000",
+ )
+ assertEquals(emptyList(), SteamDownloads.runningShaderUpdates(log))
+ }
+}
diff --git a/app/src/test/java/com/droiddeck/launcher/ui/BottomDashboardTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/BottomDashboardTest.kt
index 8bdf4013b..1fe4d535f 100644
--- a/app/src/test/java/com/droiddeck/launcher/ui/BottomDashboardTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/ui/BottomDashboardTest.kt
@@ -24,3 +24,15 @@ class BottomDashboardTest {
assertFalse(bottomTabsWithLabels(360f, 3))
}
}
+
+class BottomDownloadsRateTest {
+ @Test fun rateIsBytesPerSecondBetweenReads() {
+ assertEquals(1_000_000L, downloadRate(0L, 0L, 2_000_000L, 2_000L))
+ }
+
+ @Test fun noMovementOrARestartIsZero() {
+ assertEquals(0L, downloadRate(500L, 0L, 500L, 2_000L))
+ assertEquals(0L, downloadRate(500L, 0L, 100L, 2_000L))
+ assertEquals(0L, downloadRate(-1L, 0L, 100L, 2_000L))
+ }
+}