mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
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:
1 parent
ade2e982b6
commit
6beeb29486
16 files changed
+1083
-57
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user