feat(dual-screen): Downloads and Notifications pages on the bottom screen

Downloads: Steam downloads and updates (read from Steam's appmanifests,
including SD-card libraries), shader-cache downloads from the content log,
and DroidDeck store downloads, with progress and speed.

Notifications: other apps' notifications, after the user allows
notification access once. Kept in memory only, never saved or logged;
ongoing, summary and empty ones are skipped.
This commit is contained in:
The412Banner committed 2026-10-31 19:49:40 +00:00
1 parent e76592ba4e
commit 3be85906e9
14 files changed
+547 -35

No files matched your search

+8
View File
@@ -69,6 +69,14 @@
</intent-filter>
<meta-data android:name="android.accessibilityservice" android:resource="@xml/home_button_service" />
</service>
<service android:name=".session.BottomNotificationListener"
android:label="@string/bottom_notifications_listener"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
android:exported="true">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<receiver android:name=".core.ShortcutsListingReceiver" android:exported="false"
android:process=":shortcuts_sync">
<intent-filter>
@@ -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<BottomPage> = emptyList()
private fun bottomPages(): List<BottomPage> = sessionBottomPages()
private fun tapSteamButton() {
if (SessionState.mode == SessionService.MODE_STEAM) {
@@ -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<BottomNotification>()
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<BottomNotification>, 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)
}
@@ -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<String, File>()
private var steamLibraryMounts: Map<String, File>
get() = SessionState.steamLibraryMounts
set(value) { SessionState.steamLibraryMounts = value }
private var suspendDownloadNow = false
private var suspendPolicy = SessionPrefs.SUSPEND_MANUAL
private var steamDownloadsInBackground = false
@@ -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<String, java.io.File> = emptyMap()
@Volatile
var mode = "steam"
@@ -101,34 +101,6 @@ internal class SteamDownloadMonitor {
now - lastContentActivityAt <= CONTENT_ACTIVITY_GRACE_MS
}
private fun manifests(runtimeRoot: File, mounts: Map<String, File>): List<File> {
val steamRoots = linkedSetOf<File>()
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<String, File>): 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<String, File>): List<File> {
val steamRoots = linkedSetOf<File>()
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<String, File>): 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""")
}
}
@@ -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<String, File> = emptyMap(), client: String? = null): List<SteamDownload> {
val names = HashMap<String, String>()
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<String>): List<String> {
val state = LinkedHashMap<String, Boolean>()
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<String> = 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
}
@@ -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<List<SteamRow>>(emptyList()) }
LaunchedEffect(Unit) {
var last = emptyMap<String, Pair<Long, Long>>()
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)
}
}
@@ -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)
}
}
@@ -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<BottomPage> = listOf(
BottomPage("downloads", R.string.bottom_page_downloads, Icons.Outlined.Download) { BottomDownloadsPage() },
BottomPage("notifications", R.string.bottom_page_notifications, Icons.Outlined.Notifications) { BottomNotificationsPage() },
)
+13
View File
@@ -1877,6 +1877,19 @@
<string name="second_mode_deck_controls">Deck grips + trackpads</string>
<string name="second_mode_dashboard">Dashboard</string>
<string name="bottom_dashboard_empty">Nothing to show here yet.</string>
<string name="bottom_page_downloads">Downloads</string>
<string name="bottom_downloads_none">No downloads running.</string>
<string name="bottom_downloads_steam">Steam</string>
<string name="bottom_downloads_shader">Downloading shader cache</string>
<string name="bottom_downloads_paused">Paused</string>
<string name="bottom_downloads_queued">Waiting to start</string>
<string name="bottom_page_notifications">Notifications</string>
<string name="bottom_notifications_listener">DroidDeck bottom screen</string>
<string name="bottom_notifications_why">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.</string>
<string name="bottom_notifications_allow">Allow notification access</string>
<string name="bottom_notifications_none">No notifications.</string>
<string name="bottom_notifications_count">%1$d notifications</string>
<string name="bottom_notifications_clear">Clear</string>
<string name="rtinst_removing">Removing Linux runtime</string>
<plurals name="rtinst_removing_entries">
@@ -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<BottomNotification>()
repeat(35) { BottomNotifications.add(list, BottomNotification("k$it", "App", "t$it", "", it.toLong())) }
assertEquals(30, list.size)
assertEquals("k34", list.first().key)
}
}
@@ -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<String>(), SteamDownloads.runningShaderUpdates(log))
}
}
@@ -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))
}
}