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
This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-09-30 11:25:18 -04:00
1 parent ade2e982b6
commit 6beeb29486
16 files changed
+1083 -57

No files matched your search

+2
View File
@@ -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
+39
View File
@@ -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
+19
View File
@@ -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'
+2
View File
@@ -22,6 +22,8 @@
relay helper opens the input stream under this uid, and PulseAudio exposes it to the
Steam client as DirectAudioMic. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Installs DroidDeck's own updates (Updates page) over itself, after the user allows it. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-feature android:name="android.hardware.vulkan.version" android:version="0x401000" android:required="true" />
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
@@ -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.
@@ -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<AppUpdates.Catalog?>(null)
var follow by mutableStateOf(AppUpdates.Follow(AppUpdates.Channel.STABLE))
var checking by mutableStateOf(false)
var error by mutableStateOf<String?>(null)
var stage by mutableStateOf<String?>(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) }
}
}
@@ -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() }
@@ -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)
@@ -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(
@@ -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 {
@@ -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)
}
}
}
}
}
@@ -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") } },
)
}
@@ -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()
}
@@ -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-<sha>` release on DroidDeck-CI, one per merge to main) and Test (a `pr-<n>` 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<Release>, 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<Release>()
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: `<name><suffix>.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<String, String> = 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<String, String> {
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<String, String> =
name to lines(paragraphs(body).firstOrNull { !it.startsWith("#") && !it.startsWith("![") && !it.startsWith("<") }.orEmpty())
private fun paragraphs(body: String): List<String> =
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)
}
@@ -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" } ?: ".")
}
}
@@ -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)
}
}