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