From 6beeb29486339d9e14194556ec00b3f101aa933c Mon Sep 17 00:00:00 2001
From: Kurt Himebauch <136133082+xXJSONDeruloXx@users.noreply.github.com>
Date: Wed, 30 Sep 2026 11:25:18 -0400
Subject: [PATCH] feat: add an Updates page that installs Stable, Nightly or
test builds in-app (#98)
* feat: add an Updates page that installs Stable, Nightly or test builds in-app
* style: put update channels on the left and the build on one line
* style: make the update card read like a system updater
* feat: expand update notes, show the build beside the title, fit with button hints
* style: check for updates with the refresh icon Components uses
---
.github/workflows/build.yml | 2 +
.github/workflows/pr-release-cleanup.yml | 39 ++
app/build.gradle | 19 +
app/src/main/AndroidManifest.xml | 2 +
.../com/droiddeck/launcher/MainActivity.kt | 18 +-
.../com/droiddeck/launcher/UpdatesMenu.kt | 141 +++++++
.../droiddeck/launcher/ui/ComponentsPage.kt | 2 +-
.../droiddeck/launcher/ui/FrontEndContent.kt | 4 +
.../com/droiddeck/launcher/ui/FrontEndRail.kt | 38 +-
.../droiddeck/launcher/ui/FrontEndScreen.kt | 5 +-
.../droiddeck/launcher/ui/FrontEndSetup.kt | 11 +-
.../com/droiddeck/launcher/ui/MainScreen.kt | 22 --
.../com/droiddeck/launcher/ui/UpdatesPage.kt | 349 ++++++++++++++++++
.../droiddeck/launcher/update/AppUpdates.kt | 297 +++++++++++++++
.../launcher/update/SelfInstaller.kt | 108 ++++++
.../launcher/update/AppUpdatesTest.kt | 83 +++++
16 files changed, 1083 insertions(+), 57 deletions(-)
create mode 100644 .github/workflows/pr-release-cleanup.yml
create mode 100644 app/src/main/java/com/droiddeck/launcher/UpdatesMenu.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt
create mode 100644 app/src/main/java/com/droiddeck/launcher/update/SelfInstaller.kt
create mode 100644 app/src/test/java/com/droiddeck/launcher/update/AppUpdatesTest.kt
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 7f5d7790..f5f189da 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -148,6 +148,8 @@ jobs:
label="${BUILD_REF} at ${BUILD_SHA:0:7}"
fi
echo "label=$label" >> "$GITHUB_OUTPUT"
+ echo "DROIDDECK_SOURCE_COMMIT=${PR_HEAD_SHA:-$BUILD_SHA}" >> "$GITHUB_ENV"
+ echo "DROIDDECK_PR_NUMBER=${PR_NUMBER:-0}" >> "$GITHUB_ENV"
echo "Build: \`$label\`" >> "$GITHUB_STEP_SUMMARY"
- name: Cache the preloads
diff --git a/.github/workflows/pr-release-cleanup.yml b/.github/workflows/pr-release-cleanup.yml
new file mode 100644
index 00000000..abe506be
--- /dev/null
+++ b/.github/workflows/pr-release-cleanup.yml
@@ -0,0 +1,39 @@
+name: PR Release Cleanup
+run-name: remove PR #${{ github.event.pull_request.number }}'s test build
+
+# A merged or closed PR's test build leaves DroidDeck-CI, so the app's Updates page stops offering it
+# (a merged fix is in the next Nightly). pull_request_target for the publisher app's secrets; nothing
+# from the PR is checked out or run.
+on:
+ pull_request_target:
+ types: [ closed ]
+ branches: [ main ]
+
+permissions: {}
+
+jobs:
+ cleanup:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 5
+ steps:
+ # A token of the org's "DroidDeck CI Publisher" app, as build.yml's announce job uses.
+ - uses: actions/create-github-app-token@v2
+ id: ci-token
+ with:
+ app-id: ${{ vars.DROIDDECK_CI_APP_ID }}
+ private-key: ${{ secrets.DROIDDECK_CI_APP_KEY }}
+ owner: Droid-Deck
+ repositories: DroidDeck-CI
+ - name: Delete the test build
+ env:
+ GH_TOKEN: ${{ steps.ci-token.outputs.token }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ run: |
+ set -euo pipefail
+ tag="pr-$PR_NUMBER"
+ if gh release view "$tag" -R Droid-Deck/DroidDeck-CI >/dev/null 2>&1; then
+ gh release delete "$tag" -R Droid-Deck/DroidDeck-CI --yes --cleanup-tag
+ echo "Deleted $tag"
+ else
+ echo "No test build for PR #$PR_NUMBER"
+ fi
diff --git a/app/build.gradle b/app/build.gradle
index fac98390..37efe56d 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -49,6 +49,20 @@ if (!(treeState in ['clean', 'dirty'])) {
buildLabel = "${buildOrigin} ${treeState} · ${buildLabel}"
def escapedBuildLabel = buildLabel.replace('\\', '\\\\').replace('"', '\\"')
+// What the in-app updater compares against the published builds: the commit this build is of (a PR
+// build's head, not the merge CI makes of it), its PR, and whether CI made it at all.
+def sourceCommit = providers.environmentVariable('DROIDDECK_SOURCE_COMMIT').getOrNull()
+ ?: providers.environmentVariable('GITHUB_SHA').getOrNull() ?: gitValue(['rev-parse', 'HEAD']) ?: ''
+def prNumber = (providers.environmentVariable('DROIDDECK_PR_NUMBER').getOrNull() ?: '0').with { it.isInteger() ? it : '0' }
+def ciBuild = providers.environmentVariable('GITHUB_ACTIONS').getOrNull() == 'true'
+// When the source was committed, for "built 3 hours ago": fixed per commit, so it costs no rebuilds.
+def commitTime = (gitValue(['show', '-s', '--format=%ct', 'HEAD']) ?: '0').with { it.isLong() ? it : '0' }
+// tools/release/variants.txt as "package=suffix;...", so the updater picks the apk of the package it runs as.
+def variantSuffixes = rootProject.file('tools/release/variants.txt').readLines()
+ .findAll { it.trim() && !it.startsWith('#') }
+ .collect { def f = it.trim().split(/\s+/); "${f[1]}=${f[2] == '-' ? '' : f[2]}" }
+ .join(';')
+
android {
namespace 'com.droiddeck.launcher'
compileSdk 34
@@ -66,6 +80,11 @@ android {
versionCode 9
versionName "0.2.0"
buildConfigField "String", "BUILD_LABEL", "\"${escapedBuildLabel}\""
+ buildConfigField "String", "SOURCE_COMMIT", "\"${sourceCommit}\""
+ buildConfigField "int", "PR_NUMBER", "${prNumber}"
+ buildConfigField "boolean", "CI_BUILD", "${ciBuild}"
+ buildConfigField "long", "COMMIT_TIME", "${commitTime}L * 1000L"
+ buildConfigField "String", "VARIANT_SUFFIXES", "\"${variantSuffixes}\""
ndk {
abiFilters 'arm64-v8a'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 86a4f461..55cb7233 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -22,6 +22,8 @@
relay helper opens the input stream under this uid, and PulseAudio exposes it to the
Steam client as DirectAudioMic. -->
+
+
diff --git a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt
index 7686e699..5026b97c 100644
--- a/app/src/main/java/com/droiddeck/launcher/MainActivity.kt
+++ b/app/src/main/java/com/droiddeck/launcher/MainActivity.kt
@@ -57,7 +57,6 @@ import com.droiddeck.launcher.ui.ControllerActions
import com.droiddeck.launcher.ui.ControllerMappingPage
import com.droiddeck.launcher.input.ControllerPrefs
import com.droiddeck.launcher.input.ControllerEditorActivity
-import com.droiddeck.launcher.ui.CreditsDialog
import com.droiddeck.launcher.ui.FrontEndScreen
import com.droiddeck.launcher.ui.FrontEndState
import com.droiddeck.launcher.ui.FrontEndActions
@@ -82,6 +81,7 @@ class MainActivity : ComponentActivity() {
private val drivers = DriverMenus(this, ui)
private val components = ComponentsMenu(this, ui)
private val decky = DeckyMenu(this, ui)
+ private val updates = UpdatesMenu(this, ui)
private val protons = ProtonMenu(this, ui)
// The screen's state. Compose redraws whatever reads these when they change.
@@ -106,7 +106,6 @@ class MainActivity : ComponentActivity() {
private var noGlError by mutableStateOf(true)
private var steamDeckMode by mutableStateOf(false)
private var steamController by mutableStateOf(SessionPrefs.CONTROLLER_DECK)
- private var showCredits by mutableStateOf(false)
private var showProtons by mutableStateOf(false)
// Components page: FEX / DXVK / VKD3D-Proton per Proton (ComponentsManager).
private var showComponents by mutableStateOf(false)
@@ -314,6 +313,7 @@ class MainActivity : ComponentActivity() {
storeEnabled = SessionPrefs.storeEnabled(this)
appImagesEnabled = SessionPrefs.appImagesEnabled(this)
applyLauncherFullscreen()
+ updates.start()
setContent {
DroidDeckTheme(theme) {
val sm = settingsMode
@@ -353,6 +353,7 @@ class MainActivity : ComponentActivity() {
buildLabel = BuildConfig.BUILD_LABEL,
oscMode = oscMode,
controller = controllerSettings,
+ updates = updates.state(),
phantomProcessStatus = phantomProcessStatus,
showPhantomGate = showPhantomGate,
launcherFullscreen = launcherFullscreen,
@@ -443,7 +444,6 @@ class MainActivity : ComponentActivity() {
OfflineMode.setEnabled(this, !OfflineMode.enabled(this))
offline = OfflineMode.enabled(this)
},
- onCredits = { showCredits = true },
onPageBack = { settingsMode = null; showPerformance = false; showProtons = false; showComponents = false; showMapping = false },
onTheme = { id -> SessionPrefs.setTheme(this, id); theme = id },
onLauncherFullscreen = { on ->
@@ -463,9 +463,6 @@ class MainActivity : ComponentActivity() {
SessionPrefs.setBackActionsInverted(this, inverted)
backActionsInverted = inverted
},
- onCheckLatestBuild = {
- startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/The412Banner/DroidDeck/actions/workflows/build.yml")))
- },
onRefreshPhantomStatus = { refreshPhantomStatus() },
onOpenDeveloperOptions = { displayId -> openDeveloperOptions(displayId) },
onWirelessAdbPair = { host, port, code, complete ->
@@ -516,6 +513,7 @@ class MainActivity : ComponentActivity() {
.putExtra(android.provider.Settings.EXTRA_APP_PACKAGE, packageName)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
},
+ updates = updates.actions(),
controller = ControllerActions(
onOsc = { o -> SessionPrefs.setOscMode(this, o); oscMode = o },
onTint = { t -> ControllerPrefs.setTint(this, t); refreshController() },
@@ -557,7 +555,6 @@ class MainActivity : ComponentActivity() {
onConfirm = { Thread({ LinuxRuntimeInstaller.uninstall(this); ui.post { refresh() } }, "uninstall").start() },
onDismiss = { showRemove = false },
)
- if (showCredits) CreditsDialog { showCredits = false }
flood?.let { f -> com.droiddeck.launcher.ui.LaunchFlood(f.from, onProgress = { floodProgress = it }) { launchFlooded(f) } }
returning?.let { r ->
com.droiddeck.launcher.ui.FloodReturn(androidx.compose.ui.graphics.Color(r.color), r.to, onProgress = { floodProgress = it }) {
@@ -648,6 +645,8 @@ class MainActivity : ComponentActivity() {
returning = ReturningFlood(c, com.droiddeck.launcher.ui.LaunchOrigin.takeReturn())
}
refreshPhantomStatus()
+ // Opening the app and coming back from a session both land here.
+ updates.onResume()
// Swaps queued while a game ran on that Proton go in once nothing uses it (usually the
// session has just ended). Cheap when nothing is queued.
if (!SessionState.running) Thread({
@@ -678,6 +677,11 @@ class MainActivity : ComponentActivity() {
refreshSecondScreenDisplays()
}
+ override fun onDestroy() {
+ updates.unregister()
+ super.onDestroy()
+ }
+
override fun onStop() {
displayManager.unregisterDisplayListener(secondScreenDisplayListener)
// The session covers the page by now; coming back finds it as it was.
diff --git a/app/src/main/java/com/droiddeck/launcher/UpdatesMenu.kt b/app/src/main/java/com/droiddeck/launcher/UpdatesMenu.kt
new file mode 100644
index 00000000..e93b9f62
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/UpdatesMenu.kt
@@ -0,0 +1,141 @@
+package com.droiddeck.launcher
+
+import android.app.Activity
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.content.pm.PackageInstaller
+import android.os.Build
+import android.os.Handler
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import com.droiddeck.launcher.session.SessionState
+import com.droiddeck.launcher.ui.UpdatesActions
+import com.droiddeck.launcher.ui.UpdatesState
+import com.droiddeck.launcher.update.AppUpdates
+import com.droiddeck.launcher.update.SelfInstaller
+
+/**
+ * The Updates page's state: the channels' latest builds, the one followed, and an update being
+ * downloaded or installed. Checked when the launcher comes to the front - opening the app, and
+ * coming back from a session - at most once a minute.
+ */
+internal class UpdatesMenu(private val activity: Activity, private val ui: Handler) {
+ // Read in start(): the activity has no context yet while its fields are made.
+ var catalog by mutableStateOf(null)
+ var follow by mutableStateOf(AppUpdates.Follow(AppUpdates.Channel.STABLE))
+ var checking by mutableStateOf(false)
+ var error by mutableStateOf(null)
+ var stage by mutableStateOf(null)
+ var percent by mutableIntStateOf(-1)
+ var askPermission by mutableStateOf(false)
+ // An install waiting for the user to allow "Install unknown apps"; it goes on when they come back.
+ private var pending: AppUpdates.Release? = null
+ private var lastCheck = 0L
+ private var request = 0
+
+ fun state() = UpdatesState(catalog, follow, checking, error, stage, percent, askPermission)
+
+ fun actions() = UpdatesActions(
+ onCheck = { check(force = true) },
+ onFollow = ::choose,
+ onInstall = ::install,
+ onAllowInstalls = {
+ askPermission = false
+ runCatching { activity.startActivity(SelfInstaller.permissionIntent(activity)) }
+ .onFailure { error = "Couldn't open Android's \"Install unknown apps\" setting"; pending = null }
+ },
+ onDismissPermission = { askPermission = false; pending = null },
+ )
+
+ val hasUpdate: Boolean get() = AppUpdates.hasUpdate(catalog, follow)
+
+ /** On every return to the launcher: go on with an install the user just allowed, and check if due. */
+ fun onResume() {
+ pending?.let { r ->
+ if (SelfInstaller.canInstall(activity)) { pending = null; install(r) }
+ }
+ if (!SessionState.running) check(force = false)
+ }
+
+ fun check(force: Boolean) {
+ if (checking || stage != null) return
+ val now = System.currentTimeMillis()
+ if (!force && now - lastCheck < 60_000) return
+ lastCheck = now
+ val id = ++request
+ checking = true
+ Thread({
+ val result = runCatching { AppUpdates.refresh(activity) }
+ ui.post {
+ if (id != request) return@post
+ checking = false
+ result.onSuccess {
+ catalog = it
+ follow = AppUpdates.follow(activity, it)
+ error = null
+ }.onFailure { if (force) error = "Couldn't check: ${it.message ?: it.javaClass.simpleName}" }
+ }
+ }, "app-updates").start()
+ }
+
+ private fun choose(f: AppUpdates.Follow) {
+ AppUpdates.setFollow(activity, f)
+ follow = f
+ error = null
+ }
+
+ private fun install(r: AppUpdates.Release) {
+ if (stage != null) return
+ val apk = r.apk ?: run { error = "This build has no download for this copy of DroidDeck"; return }
+ if (SessionState.running) { error = "Stop the running session first, then update"; return }
+ if (!SelfInstaller.canInstall(activity)) { pending = r; askPermission = true; return }
+ error = null
+ stage = "Downloading"
+ percent = 0
+ Thread({
+ val result = runCatching {
+ val file = SelfInstaller.download(activity, apk) { pct -> ui.post { percent = pct } }
+ ui.post { stage = "Installing"; percent = -1 }
+ SelfInstaller.install(activity, file)
+ }
+ result.onFailure { e -> ui.post { stage = null; error = e.message ?: "The update failed" } }
+ }, "app-update-install").start()
+ }
+
+ /** PackageInstaller's answer: Android's confirm prompt to show, or why it failed. Success restarts the app. */
+ private val receiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)) {
+ PackageInstaller.STATUS_PENDING_USER_ACTION -> {
+ val confirm = if (Build.VERSION.SDK_INT >= 33) intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
+ else @Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_INTENT)
+ if (confirm == null) { stage = null; error = "Android didn't offer to install the update"; return }
+ stage = "Waiting for you to confirm"
+ activity.startActivity(confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ }
+ PackageInstaller.STATUS_SUCCESS -> stage = null
+ else -> {
+ stage = null
+ error = SelfInstaller.describeFailure(status, intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE))
+ }
+ }
+ }
+ }
+
+ /** From onCreate: the last check, the channel followed, and PackageInstaller's answers. */
+ fun start() {
+ catalog = AppUpdates.cached(activity)
+ follow = AppUpdates.follow(activity, catalog)
+ val filter = IntentFilter(SelfInstaller.ACTION_STATUS)
+ if (Build.VERSION.SDK_INT >= 33) activity.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
+ else activity.registerReceiver(receiver, filter)
+ }
+
+ fun unregister() {
+ runCatching { activity.unregisterReceiver(receiver) }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt
index 7822ef44..d0b4a4fa 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/ComponentsPage.kt
@@ -295,7 +295,7 @@ fun ComponentsPage(
}
@Composable
-private fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = false, enabled: Boolean = true, onClick: () -> Unit) {
+internal fun ToolIcon(icon: ImageVector, description: String, busy: Boolean = false, enabled: Boolean = true, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val src = remember { MutableInteractionSource() }
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt
index e480a96a..400c4b4b 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndContent.kt
@@ -149,6 +149,10 @@ private fun Content(
}
return
}
+ if (selected == "updates") {
+ UpdatesPage(s, a, modifier.padding(horizontal = padH, vertical = padV))
+ return
+ }
// Steam is a full-bleed wall of the library with Play over it.
if (selected == "steam") {
SteamHome(s, a, modifier)
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt
index 88661a11..e1cd3199 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndRail.kt
@@ -14,7 +14,6 @@ import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
-import androidx.compose.foundation.Image
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -26,10 +25,11 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.key
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -44,6 +44,7 @@ import androidx.compose.material.icons.outlined.DesktopWindows
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.SportsEsports
import androidx.compose.material.icons.outlined.Storefront
+import androidx.compose.material.icons.outlined.SystemUpdate
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material.icons.outlined.VideoLibrary
import androidx.compose.ui.graphics.vector.ImageVector
@@ -66,15 +67,14 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
-import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
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.dp
import androidx.compose.ui.unit.sp
-import com.droiddeck.launcher.R
import com.droiddeck.launcher.core.PhantomProcessLimit
// The front end's side rail: the page list, the resume entry and the controller hints under it.
@@ -135,22 +135,26 @@ internal fun SideRail(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.width(if (iconOnly) 64.dp else 92.dp).background(colors.surface).padding(vertical = if (compact) 8.dp else 12.dp),
) {
- Image(painterResource(R.drawable.logo), contentDescription = "DroidDeck", modifier = Modifier.size(if (compact) 28.dp else 34.dp))
- Spacer(Modifier.height(if (compact) 6.dp else 12.dp))
+ // Every section on screen at once: items shrink (to 44dp, still a touch target) before the rail scrolls.
+ val count = 6 + (if (s.storeEnabled) 1 else 0) + (if (s.isHomeApp) 1 else 0)
+ BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) {
+ val fit = (maxHeight / count - 4.dp).coerceIn(44.dp, if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
- modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),
+ modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()),
) {
- RailItem("Steam", Icons.Outlined.SportsEsports, "steam", selected == "steam", compact, iconOnly, onFocus = { onFocusSelect("steam") }) { onSelect("steam") }
+ RailItem("Steam", Icons.Outlined.SportsEsports, "steam", selected == "steam", compact, iconOnly, fit, onFocus = { onFocusSelect("steam") }) { onSelect("steam") }
// Always there, so the items below it never move; an empty library says how to fill it.
- RailItem("Games", Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, onFocus = { onFocusSelect("games") }) { onSelect("games") }
- RailItem("Desktop", Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") }
- if (s.storeEnabled) RailItem("Store", Icons.Outlined.Storefront, "store", selected == "store", compact, iconOnly, onFocus = { onFocusSelect("store") }) { onSelect("store") }
- RailItem("Components", Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, onFocus = { onFocusSelect("components") }) { onSelect("components") }
+ RailItem("Games", Icons.Outlined.VideoLibrary, "games", selected == "games", compact, iconOnly, fit, onFocus = { onFocusSelect("games") }) { onSelect("games") }
+ RailItem("Desktop", Icons.Outlined.DesktopWindows, "desktop", selected == "desktop", compact, iconOnly, fit, onFocus = { onFocusSelect("desktop") }) { onSelect("desktop") }
+ if (s.storeEnabled) RailItem("Store", Icons.Outlined.Storefront, "store", selected == "store", compact, iconOnly, fit, onFocus = { onFocusSelect("store") }) { onSelect("store") }
+ RailItem("Components", Icons.Outlined.Layers, "components", selected == "components", compact, iconOnly, fit, onFocus = { onFocusSelect("components") }) { onSelect("components") }
// Home mode's extra section goes last, so it shifts nothing above it.
- if (s.isHomeApp) RailItem("Apps", Icons.Outlined.Apps, "android-apps", selected == "android-apps", compact, iconOnly, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") }
- RailItem("Setup", Icons.Outlined.Tune, "setup", selected == "setup", compact, iconOnly, badge = setupNeedsAttention, onFocus = { onFocusSelect("setup") }) { onSelect("setup") }
+ if (s.isHomeApp) RailItem("Apps", Icons.Outlined.Apps, "android-apps", selected == "android-apps", compact, iconOnly, fit, onFocus = { onFocusSelect("android-apps") }) { onSelect("android-apps") }
+ RailItem("Setup", Icons.Outlined.Tune, "setup", selected == "setup", compact, iconOnly, fit, badge = setupNeedsAttention, onFocus = { onFocusSelect("setup") }) { onSelect("setup") }
+ RailItem("Updates", Icons.Outlined.SystemUpdate, "updates", selected == "updates", compact, iconOnly, fit, badge = s.updates.hasUpdate, onFocus = { onFocusSelect("updates") }) { onSelect("updates") }
+ }
}
AnimatedVisibility(s.busy, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(bottom = 8.dp)) {
@@ -172,7 +176,7 @@ internal fun SideRail(
@Composable
private fun RailItem(
- label: String, icon: ImageVector, key: String, current: Boolean, compact: Boolean, iconOnly: Boolean,
+ label: String, icon: ImageVector, key: String, current: Boolean, compact: Boolean, iconOnly: Boolean, height: Dp,
badge: Boolean = false, onFocus: () -> Unit = {}, onClick: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
@@ -204,7 +208,7 @@ private fun RailItem(
modifier = Modifier
.then(if (frontFocus != null) Modifier.focusRequester(frontFocus.railFor(key)) else Modifier)
.graphicsLayer { scaleX = scale; scaleY = scale }
- .size(width = if (iconOnly) 52.dp else 80.dp, height = if (iconOnly) 48.dp else if (compact) 52.dp else 60.dp)
+ .size(width = if (iconOnly) 52.dp else 80.dp, height = height)
.clip(Shape14)
.background(fill)
.border(2.dp, ring, Shape14)
@@ -214,7 +218,7 @@ private fun RailItem(
) {
if (iconOnly) Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(22.dp))
else Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(3.dp)) {
- Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(if (compact) 20.dp else 22.dp))
+ Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(if (height < 56.dp) 20.dp else 22.dp))
Text(label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = fg, maxLines = 1, softWrap = false)
}
if (badge) Box(
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt
index 68056606..1c0619fa 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt
@@ -117,6 +117,8 @@ class FrontEndState(
/** Beta features the user turns on in Setup: the Flathub Store and AppImage import. */
val storeEnabled: Boolean = false,
val appImagesEnabled: Boolean = false,
+ /** The Updates page: DroidDeck's own builds and the channel followed. */
+ val updates: UpdatesState = UpdatesState(),
)
class FrontEndActions(
@@ -151,7 +153,6 @@ class FrontEndActions(
val onLogs: () -> Unit,
val onShareLogs: () -> Unit = {},
val onOffline: () -> Unit,
- val onCredits: () -> Unit,
val onPageBack: () -> Unit = {},
val onTheme: (String) -> Unit = {},
val onLauncherFullscreen: (Boolean) -> Unit = {},
@@ -161,7 +162,6 @@ class FrontEndActions(
val onHomeScreen: (Boolean) -> Unit = {},
val onAndroidApp: (HomeApp.LaunchableApp, Int?) -> Unit = { _, _ -> },
val onBackActionsInverted: (Boolean) -> Unit = {},
- val onCheckLatestBuild: () -> Unit = {},
val onRefreshPhantomStatus: () -> Unit = {},
val onOpenDeveloperOptions: (Int?) -> Unit = {},
val onWirelessAdbPair: (String, Int, String, (String?) -> Unit) -> Unit = { _, _, _, done -> done("Wireless debugging is unavailable") },
@@ -173,6 +173,7 @@ class FrontEndActions(
val onStartWirelessAdbPairing: () -> Unit = {},
val onOpenNotificationSettings: () -> Unit = {},
val controller: ControllerActions? = null,
+ val updates: UpdatesActions = UpdatesActions(),
)
internal object Motion {
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt
index 4ddda06e..048a86aa 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndSetup.kt
@@ -158,8 +158,9 @@ internal fun SetupPanel(
var showLimitDetails by rememberSaveable { mutableStateOf(false) }
val checks = 4
val readyCount = listOf(gpuOk, s.ready && !s.busy, !limitBlocks, signedIn).count { it }
- // Five tabs instead of one long scroll; LB and RB turn them from anywhere on the page.
- val tabs = listOf("Overview", "Controller", "Session", "Launcher", "About")
+ // Four tabs instead of one long scroll; LB and RB turn them from anywhere on the page. Build
+ // and credits are on the Updates page.
+ val tabs = listOf("Overview", "Controller", "Session", "Launcher")
var tab by rememberSaveable { mutableStateOf(0) }
val tabFocus = remember { List(tabs.size) { FocusRequester() } }
var tabTurned by remember { mutableStateOf(false) }
@@ -327,12 +328,6 @@ internal fun SetupPanel(
) { a.onAppImagesEnabled(it) }
}
}
- else -> {
- SettingsGroup("About") {
- ActionRow("Build", s.buildLabel, "Check for newer", a.onCheckLatestBuild)
- ActionRow("Credits", "The people and projects DroidDeck builds on", "View", a.onCredits)
- }
- }
}
}
}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/MainScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/MainScreen.kt
index f7013602..94c6f593 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/MainScreen.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/MainScreen.kt
@@ -51,25 +51,3 @@ fun ConfirmDialog(title: String, text: String, confirm: String, onConfirm: () ->
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
-
-@Composable
-fun CreditsDialog(onDismiss: () -> Unit) {
- AlertDialog(
- onDismissRequest = onDismiss,
- title = { Text("Credits") },
- text = {
- Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
- Text("The412Banner: app, compositor, runtime, and Steam session.", style = MaterialTheme.typography.bodyMedium)
- Spacer(Modifier.height(10.dp))
- Text("maxjivi05: gamescope runtime and controller support, based on WinNative.", style = MaterialTheme.typography.bodyMedium)
- Spacer(Modifier.height(10.dp))
- Text(
- "GPL-3.0. Steam, Steam Deck, and Proton are Valve trademarks. Not affiliated with Valve. Third-party software remains under its authors' licenses.",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- )
- }
- },
- confirmButton = { TextButton(onClick = onDismiss) { Text("OK") } },
- )
-}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt
new file mode 100644
index 00000000..2e4d043d
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/ui/UpdatesPage.kt
@@ -0,0 +1,349 @@
+package com.droiddeck.launcher.ui
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateColorAsState
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.LocalIndication
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.hoverable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+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.heightIn
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Bolt
+import androidx.compose.material.icons.outlined.CheckCircle
+import androidx.compose.material.icons.outlined.Code
+import androidx.compose.material.icons.outlined.Download
+import androidx.compose.material.icons.outlined.Info
+import androidx.compose.material.icons.outlined.Refresh
+import androidx.compose.material.icons.outlined.Science
+import androidx.compose.material.icons.outlined.SwapHoriz
+import androidx.compose.material.icons.outlined.Sync
+import androidx.compose.material.icons.outlined.Verified
+import androidx.compose.material3.Icon
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+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.graphics.Color
+import androidx.compose.ui.semantics.Role
+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.update.AppUpdates
+import com.droiddeck.launcher.update.AppUpdates.Channel
+import com.droiddeck.launcher.update.AppUpdates.Follow
+import com.droiddeck.launcher.update.AppUpdates.Offer
+import com.droiddeck.launcher.update.AppUpdates.Release
+
+/** The Updates page: the latest build of each channel, the one followed, and an update in progress. */
+class UpdatesState(
+ val catalog: AppUpdates.Catalog? = null,
+ val follow: Follow = Follow(Channel.STABLE),
+ val checking: Boolean = false,
+ val error: String? = null,
+ /** "Downloading", "Installing", ... while an update is under way; [percent] is -1 when unknown. */
+ val stage: String? = null,
+ val percent: Int = -1,
+ /** Explain "Install unknown apps" before sending the user to it. */
+ val askPermission: Boolean = false,
+) {
+ val hasUpdate: Boolean get() = AppUpdates.hasUpdate(catalog, follow)
+}
+
+class UpdatesActions(
+ val onCheck: () -> Unit = {},
+ val onFollow: (Follow) -> Unit = {},
+ val onInstall: (Release) -> Unit = {},
+ val onAllowInstalls: () -> Unit = {},
+ val onDismissPermission: () -> Unit = {},
+)
+
+@Composable
+internal fun UpdatesPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier) {
+ val u = s.updates
+ val ua = a.updates
+ val me = remember { AppUpdates.installed() }
+ val colors = MaterialTheme.colorScheme
+ Column(modifier = modifier) {
+ PageHeader("Updates") {
+ // The build that is running, beside the title where it is always in view.
+ Text(
+ "DroidDeck ${me.version} · ${s.buildLabel}", fontSize = 12.5.sp, color = colors.onSurfaceVariant,
+ maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f).padding(top = 6.dp),
+ )
+ val checked = u.catalog?.checkedAt?.takeIf { it > 0 }
+ if (!LocalNarrowPane.current) Text(
+ if (u.checking) "Checking…" else if (checked != null) "Checked ${ago(checked)}" else "Not checked yet",
+ fontSize = 13.sp, color = colors.onSurfaceVariant,
+ )
+ ToolIcon(Icons.Outlined.Refresh, "Check for updates", busy = u.checking, enabled = !u.checking && u.stage == null, onClick = ua.onCheck)
+ }
+ Column(modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()).padding(bottom = 8.dp)) {
+ // Landscape has the width for channels and status side by side; a narrow pane stacks them.
+ if (LocalNarrowPane.current) {
+ StatusPanel(s, u, ua, me)
+ Box(Modifier.height(18.dp))
+ ChannelPicker(u, ua)
+ } else Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
+ Box(Modifier.weight(1f)) { ChannelPicker(u, ua) }
+ Box(Modifier.weight(1.15f)) { StatusPanel(s, u, ua, me) }
+ }
+ }
+ }
+ if (u.askPermission) AlertDialog(
+ onDismissRequest = ua.onDismissPermission,
+ title = { Text("Let DroidDeck update itself") },
+ text = {
+ Text(
+ "Android asks once before an app may install updates. On the next screen, turn on " +
+ "\"Allow from this source\", then come back - the update carries on by itself.",
+ )
+ },
+ confirmButton = { TextButton(onClick = ua.onAllowInstalls) { Text("Open settings") } },
+ dismissButton = { TextButton(onClick = ua.onDismissPermission) { Text("Not now") } },
+ )
+}
+
+/** Where things stand, as a system updater says it: a status line, what you'd get, and one button. */
+@Composable
+private fun StatusPanel(s: FrontEndState, u: UpdatesState, ua: UpdatesActions, me: AppUpdates.Installed) {
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ val catalog = u.catalog
+ val release = catalog?.let { AppUpdates.release(it, u.follow) }
+ val offer = if (me.ci) catalog?.let { AppUpdates.offer(it, u.follow, me) } else null
+ val name = channelName(u.follow)
+ val offered = release != null && (offer == Offer.UPDATE || offer == Offer.SWITCH)
+ class Look(val tint: Color, val status: String, val headline: String, val detail: String?)
+ val look = when {
+ !me.ci -> Look(colors.onSurfaceVariant, "Local build", "Built on a computer",
+ "It's signed differently from the builds here, so Android won't update it in place. Uninstall it to switch.")
+ catalog == null -> Look(colors.onSurfaceVariant, if (u.checking) "Checking…" else "Not checked yet", "Updates", null)
+ offer == Offer.UPDATE -> Look(AttentionAmber, "Update available", newBuild(u.follow, release!!), null)
+ offer == Offer.SWITCH -> Look(pal.signal, "Ready to switch", newBuild(u.follow, release!!), null)
+ offer == Offer.AHEAD -> Look(pal.signal, "Ahead of Stable", "You're ahead of Stable",
+ "This build is newer than the last Stable release. You'll move onto Stable with its next one.")
+ offer == Offer.GONE -> Look(AttentionAmber, "Test ended", "This test has ended",
+ "Its fix was merged or dropped. Follow Nightly to keep getting the newest fixes.")
+ else -> Look(pal.good, "Up to date", "You have the latest $name", null)
+ }
+ Column(
+ modifier = Modifier.fillMaxWidth().clip(Shape16).background(colors.surface).border(1.dp, pal.line, Shape16).padding(18.dp),
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(7.dp)) {
+ Box(Modifier.size(7.dp).clip(CircleShape).background(look.tint))
+ Text(look.status, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = look.tint)
+ if (offered) Text("· ${ago(release!!.publishedAt)}", fontSize = 13.sp, color = colors.onSurfaceVariant)
+ }
+ Text(look.headline, fontSize = 22.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, modifier = Modifier.padding(top = 4.dp))
+ if (offered) {
+ ReleaseNotes(changeTitle(release!!.title.ifBlank { release.tag }), release.summary, release.tag)
+ }
+ if (look.detail != null) Text(look.detail, fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 10.dp))
+ if (u.error != null) Text(u.error, fontSize = 13.sp, color = pal.error, modifier = Modifier.padding(top = 12.dp))
+ val installable = release?.apk != null && !s.sessionRunning
+ val button = release?.apk?.size?.takeIf { it > 0 }?.let { " · ${megabytes(it)}" }.orEmpty()
+ Box(Modifier.padding(top = 14.dp)) {
+ when {
+ u.stage != null -> Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(if (u.percent >= 0) "${u.stage}… ${u.percent}%" else "${u.stage}…", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
+ if (u.percent >= 0) LinearProgressIndicator(progress = { u.percent / 100f }, modifier = Modifier.fillMaxWidth().height(6.dp).clip(CircleShape))
+ else LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(6.dp).clip(CircleShape))
+ }
+ offer == Offer.UPDATE -> PrimaryButton("Update$button", enabled = installable, main = true) { ua.onInstall(release!!) }
+ offer == Offer.SWITCH -> PrimaryButton("Install$button", enabled = installable, main = true) { ua.onInstall(release!!) }
+ offer == Offer.AHEAD -> SecondaryButton("Install Stable ${release?.version.orEmpty()} anyway", enabled = installable) { ua.onInstall(release!!) }
+ offer == Offer.GONE -> PrimaryButton("Follow Nightly", main = true) { ua.onFollow(Follow(Channel.NIGHTLY)) }
+ }
+ }
+ if (s.sessionRunning && (offered || offer == Offer.AHEAD)) {
+ Text("Stop the running session to update.", fontSize = 12.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp))
+ }
+ }
+}
+
+/** The change and its notes, two lines each; More opens the rest when either is cut short. */
+@Composable
+private fun ReleaseNotes(title: String, notes: String, key: String) {
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ var open by rememberSaveable(key) { mutableStateOf(false) }
+ var cut by remember(key) { mutableStateOf(false) }
+ Text(
+ title, fontSize = 15.sp, color = colors.onBackground, maxLines = if (open) Int.MAX_VALUE else 2, overflow = TextOverflow.Ellipsis,
+ onTextLayout = { if (it.hasVisualOverflow) cut = true }, modifier = Modifier.padding(top = 10.dp),
+ )
+ if (notes.isNotBlank()) Text(
+ notes, fontSize = 13.5.sp, color = colors.onSurfaceVariant, maxLines = if (open) Int.MAX_VALUE else 1, overflow = TextOverflow.Ellipsis,
+ onTextLayout = { if (it.hasVisualOverflow) cut = true }, modifier = Modifier.padding(top = 4.dp),
+ )
+ if (cut || open) {
+ val src = remember { MutableInteractionSource() }
+ val hot = rememberHot(src)
+ val toggle = { open = !open }
+ Text(
+ if (open) "Less" else "More", fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, color = pal.signal,
+ modifier = Modifier.padding(top = 2.dp).offset(x = (-6).dp).paneItem("notes:more")
+ .clip(Shape12).border(2.dp, if (hot) pal.signal else Color.Transparent, Shape12)
+ .hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, role = Role.Button, onClick = toggle)
+ .controllerConfirm(onClick = toggle)
+ .padding(horizontal = 6.dp, vertical = 4.dp),
+ )
+ }
+}
+
+/** The headline for a build on offer: "DroidDeck 0.3.0", "New Nightly build", "PR #93 test build". */
+private fun newBuild(f: Follow, r: Release) = when (f.channel) {
+ Channel.STABLE -> "DroidDeck ${r.version ?: r.tag}"
+ Channel.NIGHTLY -> "New Nightly build"
+ Channel.TEST -> "PR #${r.pr} test build"
+}
+
+/** A PR title as a sentence: "fix(steam): keep the client alive" -> "Keep the client alive". */
+private fun changeTitle(t: String): String =
+ t.replace(Regex("""^[a-z]+(\([^)]*\))?!?:\s*"""), "").replaceFirstChar { it.uppercase() }
+
+private fun megabytes(bytes: Long) = "${(bytes + 524_288) / 1_048_576} MB"
+
+/** The three channels as cards to pick from; Test builds opens its list of PRs under it. */
+@Composable
+private fun ChannelPicker(u: UpdatesState, ua: UpdatesActions) {
+ val colors = MaterialTheme.colorScheme
+ val catalog = u.catalog
+ val tests = catalog?.tests.orEmpty()
+ Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Text("UPDATE CHANNEL", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(start = 2.dp, top = 2.dp))
+ ChannelCard(
+ Icons.Outlined.Verified, "Stable", "Tested releases, for most people",
+ catalog?.stable?.let { "${it.version ?: it.tag} · ${ago(it.publishedAt)}" }, u.follow.channel == Channel.STABLE,
+ ) { ua.onFollow(Follow(Channel.STABLE)) }
+ ChannelCard(
+ Icons.Outlined.Bolt, "Nightly", "Newest fixes, the odd new bug",
+ catalog?.nightly?.let { ago(it.publishedAt) }, u.follow.channel == Channel.NIGHTLY,
+ ) { ua.onFollow(Follow(Channel.NIGHTLY)) }
+ ChannelCard(
+ Icons.Outlined.Science, "Test builds", "Try a fix before it's released",
+ when (tests.size) { 0 -> "None right now"; 1 -> "1 to try"; else -> "${tests.size} to try" },
+ u.follow.channel == Channel.TEST, enabled = tests.isNotEmpty() || u.follow.channel == Channel.TEST,
+ ) { tests.firstOrNull()?.let { ua.onFollow(Follow(Channel.TEST, it.pr)) } }
+ AnimatedVisibility(u.follow.channel == Channel.TEST && tests.isNotEmpty(), enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) {
+ Column(Modifier.padding(start = 18.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ tests.forEach { t -> TestRow(t, u.follow.pr == t.pr) { ua.onFollow(Follow(Channel.TEST, t.pr)) } }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ChannelCard(
+ icon: ImageVector, title: String, hint: String, latest: String?, selected: Boolean,
+ enabled: Boolean = true, onClick: () -> Unit,
+) {
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ val src = remember { MutableInteractionSource() }
+ val hot = rememberHot(src)
+ val edge by animateColorAsState(if (hot || selected) pal.signal else pal.line, Motion.tw(200), label = "channelEdge")
+ val fill by animateColorAsState(
+ if (selected) pal.signal.copy(alpha = 0.10f) else if (hot) Color.White.copy(alpha = 0.05f) else colors.surface,
+ Motion.tw(200), label = "channelFill",
+ )
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(14.dp),
+ modifier = Modifier.fillMaxWidth().paneItem("channel:$title")
+ .clip(Shape14).background(fill).border(if (hot) 2.dp else 1.dp, edge, Shape14)
+ .alpha(if (enabled) 1f else 0.55f)
+ .hoverable(src)
+ .clickable(interactionSource = src, indication = LocalIndication.current, enabled = enabled, role = Role.RadioButton, onClick = onClick)
+ .controllerConfirm(enabled = enabled, onClick = onClick)
+ .padding(horizontal = 14.dp, vertical = 10.dp),
+ ) {
+ Box(contentAlignment = Alignment.Center, modifier = Modifier.size(38.dp).clip(Shape12).background(if (selected) pal.signal.copy(alpha = 0.18f) else colors.surfaceVariant)) {
+ Icon(icon, contentDescription = null, tint = if (selected) pal.signal else colors.onSurfaceVariant, modifier = Modifier.size(22.dp))
+ }
+ Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(title, fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground)
+ if (latest != null) Text(latest, fontSize = 12.sp, fontWeight = FontWeight.Medium, color = if (selected) pal.signal else colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ Text(hint, fontSize = 12.5.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ Box(contentAlignment = Alignment.Center, modifier = Modifier.size(22.dp).clip(CircleShape).border(2.dp, if (selected) pal.signal else pal.line2, CircleShape)) {
+ if (selected) Box(Modifier.size(11.dp).clip(CircleShape).background(pal.signal))
+ }
+ }
+}
+
+/** One test build under Test builds: its PR, what it fixes, and how fresh it is. */
+@Composable
+private fun TestRow(t: Release, selected: Boolean, onClick: () -> Unit) {
+ val colors = MaterialTheme.colorScheme
+ val pal = LocalPalette.current
+ val src = remember { MutableInteractionSource() }
+ val hot = rememberHot(src)
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.fillMaxWidth().paneItem("test:${t.pr}")
+ .heightIn(min = 52.dp)
+ .clip(Shape12)
+ .background(if (selected) pal.signal.copy(alpha = 0.08f) else colors.surface)
+ .border(if (hot) 2.dp else 1.dp, if (hot || selected) pal.signal else pal.line, Shape12)
+ .hoverable(src)
+ .clickable(interactionSource = src, indication = LocalIndication.current, role = Role.RadioButton, onClick = onClick)
+ .controllerConfirm(onClick = onClick)
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ ) {
+ Text("#${t.pr}", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = if (selected) pal.signal else colors.onSurfaceVariant)
+ Column(Modifier.weight(1f)) {
+ Text(t.title.ifBlank { "PR #${t.pr}" }, fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ Text(if (t.apk == null) "Not built for this copy of DroidDeck" else "Updated ${ago(t.publishedAt)}", fontSize = 12.sp, color = colors.onSurfaceVariant)
+ }
+ }
+}
+
+private fun channelName(f: Follow) = when (f.channel) {
+ Channel.STABLE -> "Stable"
+ Channel.NIGHTLY -> "Nightly"
+ Channel.TEST -> "the PR #${f.pr} test"
+}
+
+private fun ago(millis: Long): String {
+ if (millis <= 0) return "a while ago"
+ if (System.currentTimeMillis() - millis in 0 until 60_000) return "just now"
+ return android.text.format.DateUtils.getRelativeTimeSpanString(
+ millis, System.currentTimeMillis(), android.text.format.DateUtils.MINUTE_IN_MILLIS,
+ ).toString()
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt b/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt
new file mode 100644
index 00000000..955ea2b1
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/update/AppUpdates.kt
@@ -0,0 +1,297 @@
+package com.droiddeck.launcher.update
+
+import android.content.Context
+import com.droiddeck.launcher.BuildConfig
+import com.droiddeck.launcher.core.Hashes
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.IOException
+import java.net.HttpURLConnection
+import java.net.URL
+import java.time.Instant
+
+/**
+ * DroidDeck's own builds, on three channels: Stable (the org repo's latest release), Nightly (the
+ * newest `main-` release on DroidDeck-CI, one per merge to main) and Test (a `pr-` release
+ * on DroidDeck-CI, a signed build of an open PR). All three are signed with the release key, so any
+ * of them installs over any other and keeps the app's data.
+ *
+ * Which build is newer is decided without a diff of the repo (GitHub's compare answers in
+ * megabytes): main is linear, so the newest main build is the newest; Stable is ordered by its
+ * version; a test build is newer when its PR's head moved. The last check is remembered, so the
+ * page shows it without going online.
+ */
+object AppUpdates {
+ private const val REPO = "Droid-Deck/DroidDeck"
+ private const val CI_REPO = "Droid-Deck/DroidDeck-CI"
+ private const val PREFS = "app_updates"
+ private const val KEY_CATALOG = "catalog"
+ private const val KEY_CHANNEL = "channel"
+
+ enum class Channel { STABLE, NIGHTLY, TEST }
+
+ /** The channel the user follows; [pr] is the followed test build's PR, for [Channel.TEST]. */
+ data class Follow(val channel: Channel, val pr: Int = 0)
+
+ class Apk(val name: String, val url: String, val size: Long, val sha256: String)
+
+ /**
+ * One published build. [commit] is a full or short SHA of the source it was built from; [pr] is
+ * 0 for a main or stable build. [apk] is the file for the package this app runs as, or null when
+ * the build has none (a variant a release predates).
+ */
+ class Release(
+ val tag: String, val title: String, val summary: String, val commit: String, val pr: Int,
+ val version: String?, val publishedAt: Long, val url: String, val apk: Apk?,
+ )
+
+ class Catalog(val stable: Release?, val nightly: Release?, val tests: List, val checkedAt: Long)
+
+ /** What is running: the commit it was built from, its PR (0 for none), whether CI made it, and when it was committed. */
+ class Installed(val commit: String, val pr: Int, val version: String, val ci: Boolean, val committedAt: Long = 0L)
+
+ enum class Offer {
+ /** This is the build the channel has. */
+ CURRENT,
+ /** The channel has a newer build of what is running. */
+ UPDATE,
+ /** The channel's build is a different line (a test build from Nightly, or back); installing moves over. */
+ SWITCH,
+ /** What is running is newer than the channel's build (a Nightly past the last Stable). */
+ AHEAD,
+ /** The followed test build is gone: its PR was merged or closed. */
+ GONE,
+ }
+
+ fun installed(): Installed = Installed(BuildConfig.SOURCE_COMMIT, BuildConfig.PR_NUMBER, BuildConfig.VERSION_NAME, BuildConfig.CI_BUILD, BuildConfig.COMMIT_TIME)
+
+ fun release(catalog: Catalog, follow: Follow): Release? = when (follow.channel) {
+ Channel.STABLE -> catalog.stable
+ Channel.NIGHTLY -> catalog.nightly
+ Channel.TEST -> catalog.tests.firstOrNull { it.pr == follow.pr }
+ }
+
+ fun offer(catalog: Catalog, follow: Follow, me: Installed = installed()): Offer {
+ val r = release(catalog, follow) ?: return Offer.GONE
+ if (isRunning(r, me)) return Offer.CURRENT
+ return when (follow.channel) {
+ Channel.STABLE -> when {
+ me.pr != 0 -> Offer.SWITCH
+ compareVersions(r.version ?: "", me.version) > 0 -> Offer.UPDATE
+ else -> Offer.AHEAD
+ }
+ Channel.NIGHTLY -> if (me.pr != 0) Offer.SWITCH else Offer.UPDATE
+ Channel.TEST -> if (me.pr == r.pr) Offer.UPDATE else Offer.SWITCH
+ }
+ }
+
+ /** The dot on the rail: the followed channel has a newer build of what is running. */
+ fun hasUpdate(catalog: Catalog?, follow: Follow): Boolean =
+ catalog != null && installed().ci && offer(catalog, follow) == Offer.UPDATE
+
+ fun isRunning(r: Release, me: Installed = installed()): Boolean {
+ if (me.commit.length < 7 || r.commit.length < 7 || r.pr != me.pr) return false
+ return me.commit.startsWith(r.commit, ignoreCase = true) || r.commit.startsWith(me.commit, ignoreCase = true)
+ }
+
+ /**
+ * The channel followed. Until the user picks one it is worked out once from what is running and
+ * kept: a test build follows its PR, the Stable release follows Stable, and a main build Nightly.
+ */
+ fun follow(context: Context, catalog: Catalog?): Follow {
+ stored(context)?.let { return it }
+ val me = installed()
+ val inferred = when {
+ me.pr != 0 -> Follow(Channel.TEST, me.pr)
+ catalog == null -> return Follow(Channel.STABLE)
+ catalog.stable != null && isRunning(catalog.stable, me) -> Follow(Channel.STABLE)
+ me.ci -> Follow(Channel.NIGHTLY)
+ else -> Follow(Channel.STABLE)
+ }
+ setFollow(context, inferred)
+ return inferred
+ }
+
+ fun setFollow(context: Context, follow: Follow) {
+ val value = if (follow.channel == Channel.TEST) "test:${follow.pr}" else follow.channel.name.lowercase()
+ prefs(context).edit().putString(KEY_CHANNEL, value).apply()
+ }
+
+ private fun stored(context: Context): Follow? {
+ val v = prefs(context).getString(KEY_CHANNEL, null) ?: return null
+ return when {
+ v == "stable" -> Follow(Channel.STABLE)
+ v == "nightly" -> Follow(Channel.NIGHTLY)
+ v.startsWith("test:") -> v.removePrefix("test:").toIntOrNull()?.let { Follow(Channel.TEST, it) }
+ else -> null
+ }
+ }
+
+ /** The last check, or null before the first. */
+ fun cached(context: Context): Catalog? {
+ val raw = prefs(context).getString(KEY_CATALOG, null) ?: return null
+ return runCatching { readCatalog(JSONObject(raw)) }.getOrNull()
+ }
+
+ /** Read every channel now and remember the result: three small requests, four for an annotated tag. */
+ fun refresh(context: Context): Catalog {
+ val suffix = variantSuffix(context.packageName)
+ val stable = runCatching { stable(suffix) }.getOrElse { if (it is NoRelease) null else throw it }
+ val ci = JSONArray(get("https://api.github.com/repos/$CI_REPO/releases?per_page=50"))
+ var nightly: Release? = null
+ val tests = ArrayList()
+ for (i in 0 until ci.length()) {
+ val r = ci.getJSONObject(i)
+ if (r.optBoolean("draft")) continue
+ val tag = r.getString("tag_name")
+ when {
+ tag.startsWith("main-") -> {
+ val candidate = ciRelease(r, suffix, tag.removePrefix("main-"), 0)
+ if (nightly == null || candidate.publishedAt > nightly.publishedAt) nightly = candidate
+ }
+ tag.startsWith("pr-") -> {
+ val pr = tag.removePrefix("pr-").toIntOrNull() ?: continue
+ val body = r.optString("body")
+ // Only builds of this repo's PRs: a test build from before the org move names another.
+ if (!body.contains("github.com/$REPO/pull/$pr")) continue
+ val commit = Regex("""Commit: `?([0-9a-f]{7,40})""").find(body)?.groupValues?.get(1) ?: continue
+ tests.add(ciRelease(r, suffix, commit, pr))
+ }
+ }
+ }
+ tests.sortByDescending { it.publishedAt }
+ val catalog = Catalog(stable, nightly, tests, System.currentTimeMillis())
+ prefs(context).edit().putString(KEY_CATALOG, writeCatalog(catalog).toString()).apply()
+ return catalog
+ }
+
+ private class NoRelease : IOException("no stable release")
+
+ private fun stable(suffix: String): Release {
+ val r = try {
+ JSONObject(get("https://api.github.com/repos/$REPO/releases/latest"))
+ } catch (e: IOException) {
+ if (e.message?.contains("HTTP 404") == true) throw NoRelease() else throw e
+ }
+ val tag = r.getString("tag_name")
+ // The tag's commit; an annotated tag points at a tag object first.
+ var obj = JSONObject(get("https://api.github.com/repos/$REPO/git/ref/tags/$tag")).getJSONObject("object")
+ if (obj.getString("type") == "tag") obj = JSONObject(get(obj.getString("url"))).getJSONObject("object")
+ val (title, summary) = describeStable(r.optString("name").ifBlank { tag }, r.optString("body"))
+ return Release(tag, title, summary, obj.getString("sha"), 0, tag.removePrefix("v"), published(r),
+ r.optString("html_url"), apk(r, suffix))
+ }
+
+ private fun ciRelease(r: JSONObject, suffix: String, commit: String, pr: Int): Release {
+ val (title, summary) = describeCi(r.optString("name"), r.optString("body"))
+ return Release(r.getString("tag_name"), title, summary, commit, pr, null, published(r), r.optString("html_url"), apk(r, suffix))
+ }
+
+ private fun published(r: JSONObject): Long =
+ runCatching { Instant.parse(r.optString("published_at")).toEpochMilli() }.getOrDefault(0L)
+
+ /** The apk for this package: `.apk`, and for the standard app the one with no other variant's suffix. */
+ private fun apk(r: JSONObject, suffix: String): Apk? {
+ val others = variantSuffixes().values.filter { it.isNotEmpty() && it != suffix }
+ val assets = r.optJSONArray("assets") ?: return null
+ for (i in 0 until assets.length()) {
+ val a = assets.getJSONObject(i)
+ val name = a.getString("name")
+ if (!name.endsWith("$suffix.apk") || others.any { name.endsWith("$it.apk") }) continue
+ // Only a file GitHub has a sha256 for: the download is checked against it.
+ val sha = Hashes.githubSha256(a.optString("digest")) ?: continue
+ return Apk(name, a.getString("browser_download_url"), a.optLong("size"), sha)
+ }
+ return null
+ }
+
+ private fun variantSuffixes(): Map = BuildConfig.VARIANT_SUFFIXES.split(';')
+ .mapNotNull { e -> e.split('=', limit = 2).takeIf { it.size == 2 }?.let { it[0] to it[1] } }.toMap()
+
+ private fun variantSuffix(packageName: String): String = variantSuffixes()[packageName] ?: ""
+
+ /**
+ * A CI release's notes open with what it is: "**Merged to main** - [PR #n](…): title (merged …)"
+ * or "**Not merged** - a test build of open [PR #n](…): title", then a paragraph from the PR.
+ */
+ internal fun describeCi(name: String, body: String): Pair {
+ val paragraphs = paragraphs(body)
+ val head = paragraphs.firstOrNull().orEmpty()
+ val title = head.substringAfter("): ", "").substringBeforeLast(" (merged").ifBlank { plain(head) }.ifBlank { name }
+ val summary = paragraphs.getOrNull(1)?.takeUnless { it.startsWith("Signed build") || it.startsWith("Source:") }.orEmpty()
+ return plain(title) to lines(summary)
+ }
+
+ /** A stable release: its name, and the first paragraph of its notes that is prose rather than a heading or picture. */
+ private fun describeStable(name: String, body: String): Pair =
+ name to lines(paragraphs(body).firstOrNull { !it.startsWith("#") && !it.startsWith("![") && !it.startsWith("<") }.orEmpty())
+
+ private fun paragraphs(body: String): List =
+ body.replace("\r", "").split(Regex("\n\\s*\n")).map { it.trim() }.filter { it.isNotEmpty() }
+
+ /** A paragraph's lines as text, a list item's marker as a bullet: the page shows them collapsed or in full. */
+ private fun lines(md: String): String = md.lines().map { line ->
+ val item = Regex("""^\s*[-*]\s+""").find(line)
+ if (item != null) "• " + plain(line.substring(item.range.last + 1)) else plain(line)
+ }.filter { it.isNotEmpty() }.joinToString("\n")
+
+ /** Markdown down to text: links to their words, emphasis and code marks gone, on one line. */
+ private fun plain(md: String): String = md
+ .replace(Regex("""!\[[^]]*]\([^)]*\)"""), "")
+ .replace(Regex("""\[([^]]*)]\([^)]*\)"""), "$1")
+ .replace(Regex("""[*_`]{1,3}"""), "")
+ .replace(Regex("""\s+"""), " ")
+ .trim()
+
+ /** Dotted versions, number by number: "0.10.0" is past "0.9.2"; a missing part counts as 0. */
+ fun compareVersions(a: String, b: String): Int {
+ val x = a.removePrefix("v").split('.', '-').map { it.toIntOrNull() ?: 0 }
+ val y = b.removePrefix("v").split('.', '-').map { it.toIntOrNull() ?: 0 }
+ for (i in 0 until maxOf(x.size, y.size)) {
+ val c = (x.getOrElse(i) { 0 }).compareTo(y.getOrElse(i) { 0 })
+ if (c != 0) return c
+ }
+ return 0
+ }
+
+ private fun get(url: String): String {
+ val c = URL(url).openConnection() as HttpURLConnection
+ c.connectTimeout = 15_000
+ c.readTimeout = 20_000
+ c.setRequestProperty("Accept", "application/vnd.github+json")
+ c.setRequestProperty("User-Agent", "DroidDeck-app")
+ try {
+ val code = c.responseCode
+ if (code == 403 || code == 429) throw IOException("GitHub's rate limit was hit - try again later")
+ if (code != 200) throw IOException("GitHub answered HTTP $code")
+ return c.inputStream.bufferedReader().use { it.readText() }
+ } finally {
+ c.disconnect()
+ }
+ }
+
+ private fun writeRelease(r: Release): JSONObject = JSONObject()
+ .put("tag", r.tag).put("title", r.title).put("summary", r.summary).put("commit", r.commit).put("pr", r.pr)
+ .put("version", r.version).put("publishedAt", r.publishedAt).put("url", r.url)
+ .put("apk", r.apk?.let { JSONObject().put("name", it.name).put("url", it.url).put("size", it.size).put("sha256", it.sha256) })
+
+ private fun readRelease(o: JSONObject): Release = Release(
+ o.getString("tag"), o.optString("title"), o.optString("summary"), o.getString("commit"), o.optInt("pr"),
+ o.optString("version").ifEmpty { null }, o.optLong("publishedAt"), o.optString("url"),
+ o.optJSONObject("apk")?.let { Apk(it.getString("name"), it.getString("url"), it.optLong("size"), it.getString("sha256")) },
+ )
+
+ private fun writeCatalog(c: Catalog): JSONObject = JSONObject()
+ .put("stable", c.stable?.let(::writeRelease)).put("nightly", c.nightly?.let(::writeRelease))
+ .put("tests", JSONArray().apply { c.tests.forEach { put(writeRelease(it)) } }).put("checkedAt", c.checkedAt)
+
+ private fun readCatalog(o: JSONObject): Catalog {
+ val tests = o.optJSONArray("tests") ?: JSONArray()
+ return Catalog(
+ o.optJSONObject("stable")?.let(::readRelease), o.optJSONObject("nightly")?.let(::readRelease),
+ (0 until tests.length()).map { readRelease(tests.getJSONObject(it)) }, o.optLong("checkedAt"),
+ )
+ }
+
+ private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/update/SelfInstaller.kt b/app/src/main/java/com/droiddeck/launcher/update/SelfInstaller.kt
new file mode 100644
index 00000000..3a6e36fe
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/update/SelfInstaller.kt
@@ -0,0 +1,108 @@
+package com.droiddeck.launcher.update
+
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageInstaller
+import android.net.Uri
+import android.os.Build
+import android.provider.Settings
+import com.droiddeck.launcher.core.FileUtils
+import com.droiddeck.launcher.core.Hashes
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.net.HttpURLConnection
+import java.net.URL
+
+/**
+ * Installs one of DroidDeck's own builds over itself: download into the cache, check the file
+ * against GitHub's sha256, then hand it to PackageInstaller. Android shows its own "Update this
+ * app?" prompt, and the app is restarted on the new build.
+ */
+object SelfInstaller {
+ /** The broadcast PackageInstaller answers on; the launcher registers for it while it is open. */
+ const val ACTION_STATUS = "com.droiddeck.launcher.update.INSTALL_STATUS"
+
+ /** Android lets an app install packages only once the user allows it in "Install unknown apps". */
+ fun canInstall(context: Context): Boolean =
+ Build.VERSION.SDK_INT < 26 || context.packageManager.canRequestPackageInstalls()
+
+ fun permissionIntent(context: Context): Intent =
+ Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:${context.packageName}"))
+
+ /** Downloads [apk] into the cache and checks it; a download left from before is replaced. */
+ fun download(context: Context, apk: AppUpdates.Apk, progress: (Int) -> Unit): File {
+ val dir = File(context.cacheDir, "updates")
+ FileUtils.delete(dir)
+ dir.mkdirs()
+ val target = File(dir, apk.name)
+ val c = URL(apk.url).openConnection() as HttpURLConnection
+ c.connectTimeout = 15_000
+ c.readTimeout = 60_000
+ c.setRequestProperty("User-Agent", "DroidDeck-app")
+ c.instanceFollowRedirects = true
+ try {
+ if (c.responseCode != 200) throw IOException("The download answered HTTP ${c.responseCode}")
+ val total = c.contentLengthLong.takeIf { it > 0 } ?: apk.size
+ c.inputStream.use { input ->
+ FileOutputStream(target).use { out ->
+ val buf = ByteArray(1 shl 16)
+ var done = 0L
+ var last = -1
+ while (true) {
+ val r = input.read(buf)
+ if (r <= 0) break
+ out.write(buf, 0, r)
+ done += r
+ val pct = if (total > 0) (done * 100 / total).toInt() else -1
+ if (pct != last) { last = pct; progress(pct) }
+ }
+ }
+ }
+ if (!apk.sha256.equals(Hashes.sha256(target), ignoreCase = true)) {
+ throw IOException("The download didn't match its checksum and was discarded - try again")
+ }
+ return target
+ } catch (e: Exception) {
+ FileUtils.delete(target)
+ throw e
+ } finally {
+ c.disconnect()
+ }
+ }
+
+ /** Writes [apk] into an install session and commits it; the answer comes as an [ACTION_STATUS] broadcast. */
+ fun install(context: Context, apk: File) {
+ val installer = context.packageManager.packageInstaller
+ val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply {
+ setAppPackageName(context.packageName)
+ setSize(apk.length())
+ // Skips Android's prompt where it allows that (the app installed its current build itself).
+ if (Build.VERSION.SDK_INT >= 31) setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
+ }
+ val id = installer.createSession(params)
+ installer.openSession(id).use { session ->
+ apk.inputStream().use { input ->
+ session.openWrite("base.apk", 0, apk.length()).use { out ->
+ input.copyTo(out, 1 shl 16)
+ session.fsync(out)
+ }
+ }
+ val status = Intent(ACTION_STATUS).setPackage(context.packageName)
+ val flags = PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE else 0)
+ session.commit(PendingIntent.getBroadcast(context, id, status, flags).intentSender)
+ }
+ }
+
+ /** What went wrong, in words for the user, from PackageInstaller's status and message. */
+ fun describeFailure(status: Int, message: String?): String = when {
+ message?.contains("VERSION_DOWNGRADE") == true ->
+ "Android won't install an older version over a newer one. Stay on this build until the channel catches up."
+ status == PackageInstaller.STATUS_FAILURE_CONFLICT || message?.contains("UPDATE_INCOMPATIBLE") == true ->
+ "This copy of DroidDeck is signed differently (a local build?), so Android won't update it in place."
+ status == PackageInstaller.STATUS_FAILURE_STORAGE -> "There isn't enough free space for the update."
+ status == PackageInstaller.STATUS_FAILURE_ABORTED -> "The update was cancelled."
+ else -> "The update didn't install" + (message?.let { ": $it" } ?: ".")
+ }
+}
diff --git a/app/src/test/java/com/droiddeck/launcher/update/AppUpdatesTest.kt b/app/src/test/java/com/droiddeck/launcher/update/AppUpdatesTest.kt
new file mode 100644
index 00000000..2ac5f5e8
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/update/AppUpdatesTest.kt
@@ -0,0 +1,83 @@
+package com.droiddeck.launcher.update
+
+import com.droiddeck.launcher.update.AppUpdates.Channel
+import com.droiddeck.launcher.update.AppUpdates.Follow
+import com.droiddeck.launcher.update.AppUpdates.Installed
+import com.droiddeck.launcher.update.AppUpdates.Offer
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class AppUpdatesTest {
+ private fun release(commit: String, pr: Int = 0, version: String? = null) =
+ AppUpdates.Release("t", "title", "", commit, pr, version, 0L, "", null)
+
+ private val stable = release("aaaaaaa1111111111111111111111111111111", version = "0.2.0")
+ private val catalog = AppUpdates.Catalog(
+ stable = stable,
+ nightly = release("bbbbbbb"),
+ tests = listOf(release("ccccccc2222222222222222222222222222222", pr = 93)),
+ checkedAt = 0L,
+ )
+
+ private fun me(commit: String, pr: Int = 0, version: String = "0.2.0") = Installed(commit, pr, version, ci = true)
+
+ @Test
+ fun theStableBuildIsCurrentOnStable() {
+ assertEquals(Offer.CURRENT, AppUpdates.offer(catalog, Follow(Channel.STABLE), me(stable.commit)))
+ }
+
+ @Test
+ fun aNightlyPastStableIsAheadNotBehind() {
+ assertEquals(Offer.AHEAD, AppUpdates.offer(catalog, Follow(Channel.STABLE), me("ddddddd999")))
+ }
+
+ @Test
+ fun aNewerStableVersionIsAnUpdate() {
+ assertEquals(Offer.UPDATE, AppUpdates.offer(catalog, Follow(Channel.STABLE), me("eeeeeee999", version = "0.1.9")))
+ }
+
+ @Test
+ fun nightlyMatchesItsShortShaAndOffersTheNewest() {
+ assertEquals(Offer.CURRENT, AppUpdates.offer(catalog, Follow(Channel.NIGHTLY), me("bbbbbbb0123456789")))
+ assertEquals(Offer.UPDATE, AppUpdates.offer(catalog, Follow(Channel.NIGHTLY), me(stable.commit)))
+ }
+
+ @Test
+ fun testBuildsUpdateWithinTheirPrAndSwitchOtherwise() {
+ assertEquals(Offer.UPDATE, AppUpdates.offer(catalog, Follow(Channel.TEST, 93), me("fffffff000", pr = 93)))
+ assertEquals(Offer.SWITCH, AppUpdates.offer(catalog, Follow(Channel.TEST, 93), me(stable.commit)))
+ assertEquals(Offer.SWITCH, AppUpdates.offer(catalog, Follow(Channel.NIGHTLY), me("ccccccc2", pr = 93)))
+ assertEquals(Offer.GONE, AppUpdates.offer(catalog, Follow(Channel.TEST, 12), me("ccccccc2", pr = 12)))
+ }
+
+ @Test
+ fun aTestBuildIsNeverTheMainBuildOfTheSameCommit() {
+ assertEquals(Offer.SWITCH, AppUpdates.offer(catalog, Follow(Channel.NIGHTLY), me("bbbbbbb", pr = 5)))
+ }
+
+ @Test
+ fun versionsCompareByNumberNotText() {
+ assertTrue(AppUpdates.compareVersions("0.10.0", "0.9.2") > 0)
+ assertTrue(AppUpdates.compareVersions("v0.2.1", "0.2") > 0)
+ assertEquals(0, AppUpdates.compareVersions("0.2", "0.2.0"))
+ }
+
+ @Test
+ fun ciNotesGiveTheChangeAndItsSummary() {
+ val (title, summary) = AppUpdates.describeCi(
+ "main 8a61a18",
+ "**Merged to main** - [PR #95](https://github.com/Droid-Deck/DroidDeck/pull/95): fix: keep `Steam` alive (merged 2026-09-30)\n\n" +
+ "Steam no longer **exits** on resume.\n\nSigned build of https://github.com/x - in main.",
+ )
+ assertEquals("fix: keep Steam alive", title)
+ assertEquals("Steam no longer exits on resume.", summary)
+ val (prTitle, prSummary) = AppUpdates.describeCi(
+ "PR #93",
+ "**Not merged** - a test build of open [PR #93](https://github.com/Droid-Deck/DroidDeck/pull/93): fix: report Adreno 840v2\n\n" +
+ "Source: https://github.com/Droid-Deck/DroidDeck/pull/93\nCommit: 93382ff",
+ )
+ assertEquals("fix: report Adreno 840v2", prTitle)
+ assertEquals("", prSummary)
+ }
+}