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)) + } +}