mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-13 03:51:10 +00:00
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:
1 parent
e76592ba4e
commit
3be85906e9
14 files changed
+547
-35
No files matched your search
@@ -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() },
|
||||
)
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user