Compare commits
41
Commits
b1e55adc14
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0192c0744c | ||
|
|
be642cd53f | ||
|
|
7529266f5b | ||
|
|
7d5dcd454e | ||
|
|
3fa5d9fc3a | ||
|
|
fa75bd08d7 | ||
|
|
de41169ccb | ||
|
|
165c87047e | ||
|
|
05bc85f2b8 | ||
|
|
d1fe3758d5 | ||
|
|
664b129947 | ||
|
|
c9fa18ecb7 | ||
|
|
3077ed75fe | ||
|
|
97e84a79eb | ||
|
|
a29ceaaeab | ||
|
|
54b2720d76 | ||
|
|
fe142e3451 | ||
|
|
3bdfa94f39 | ||
|
|
22ea27efff | ||
|
|
09bd71629a | ||
|
|
c83670d216 | ||
|
|
1e9aed214b | ||
|
|
3638d1d822 | ||
|
|
18618bd135 | ||
|
|
d2985f3841 | ||
|
|
f70df9f827 | ||
|
|
439c8625d5 | ||
|
|
1d67c160b2 | ||
|
|
237380b122 | ||
|
|
4f077736b6 | ||
|
|
727f68950e | ||
|
|
aa9f534f3c | ||
|
|
011b4d9a9c | ||
|
|
d5aa081157 | ||
|
|
a99f855e98 | ||
|
|
7e4727fc49 | ||
|
|
1b7fa635d8 | ||
|
|
57d2299180 | ||
|
|
fa7ea41ccf | ||
|
|
324059b2bd | ||
|
|
1138d75a45 |
+40
-103
@@ -2,9 +2,9 @@ name: release
|
||||
|
||||
# Builds and pushes the minstrel container image to the Gitea registry.
|
||||
#
|
||||
# push to dev → :dev (freshly-built dev APK bundled)
|
||||
# push to main → :latest + :<sha> (latest-release APK bundled)
|
||||
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled)
|
||||
# push to dev → :dev (freshly-built dev APK bundled)
|
||||
# push to main → :latest + :<sha> (freshly-built stable APK bundled)
|
||||
# push tag vYYYY.MM.DD.HHMM → :latest (fresh APK bundled, and attached to the Release)
|
||||
# workflow_dispatch → manual trigger (same rules based on the ref)
|
||||
#
|
||||
# That is the whole tag map.
|
||||
@@ -41,6 +41,12 @@ name: release
|
||||
# image in the same run from the same commit, and the channel cannot go
|
||||
# stale against its own artifact.
|
||||
#
|
||||
# Every branch build carries the app built from its own commit, so a merge to
|
||||
# main is all it takes to put a new app in front of stable phones: `:latest`
|
||||
# serves it from /api/client/apk and the in-app update offers it. A release
|
||||
# is an optional bookmark — a named point in history with the APK attached
|
||||
# for download — never a step the app has to wait for.
|
||||
#
|
||||
# Release model: the tag IS the artifact's version name with a `v` in front.
|
||||
# `v2026.09.10.1432` and `2026.09.10.1432` are the same string, derived from
|
||||
# the tagged commit's UTC timestamp — so there is no mismatch to reconcile
|
||||
@@ -56,20 +62,14 @@ name: release
|
||||
# :latest is updated by every main push AND every tag push, so it always
|
||||
# reflects the newest blessed image.
|
||||
#
|
||||
# APK pipeline: on tag pushes the android-release job builds + signs the
|
||||
# Android APK and uploads it as a workflow artifact. The image-release
|
||||
# job declares `needs: android-release`, so the docker image cannot
|
||||
# start building until the APK is guaranteed-ready — no polling, no
|
||||
# race, no silent-failure mode. Attaching the APK to the gitea Release is
|
||||
# its own job (release-assets), behind the test gate below.
|
||||
#
|
||||
# :latest always carries an APK. Because every main push also moves
|
||||
# :latest (not just tags), a main build with no APK would silently strip
|
||||
# the in-app update channel off :latest until the next release. So on
|
||||
# non-tag builds image-release pulls the MOST RECENT release's signed APK
|
||||
# AND the version sidecar published beside it — the recorded values, not
|
||||
# recomputed ones — so no rebuild is needed, just a rebundle. Tag builds
|
||||
# keep bundling their own freshly-built APK.
|
||||
# APK pipeline: on every push — dev, main and tags alike — the
|
||||
# android-release job builds + signs the Android APK and uploads it as a
|
||||
# workflow artifact. The image-release job declares `needs: android-release`
|
||||
# and requires its success, so the docker image cannot start building until
|
||||
# the APK is guaranteed-ready — no polling, no race, no silent-failure mode,
|
||||
# and no image that moves a channel without an app inside it. Attaching the
|
||||
# APK to the gitea Release is its own job (release-assets), tags only, behind
|
||||
# the test gate below.
|
||||
#
|
||||
# THE GATE. Every verifying lane lives in this file —
|
||||
# Go (vet, lint, short tests), the Postgres integration suite, the web app
|
||||
@@ -400,11 +400,14 @@ jobs:
|
||||
# ------------------------------------------------------------ artifacts --
|
||||
|
||||
android-release:
|
||||
name: Build signed APK (releases and dev)
|
||||
# Also builds on `dev`, which is what makes a test channel possible at
|
||||
# all: without it the only way to get a build onto a phone is to cut a
|
||||
# release, which quietly turns `main` into the staging area.
|
||||
if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev'
|
||||
name: Build signed APK
|
||||
# Builds on every ref, so each channel's image carries the app from its
|
||||
# own commit: `dev` for the test channel, `main` for stable phones, and a
|
||||
# tag for the copy attached to its Release.
|
||||
if: >-
|
||||
startsWith(github.ref, 'refs/tags/v')
|
||||
|| github.ref == 'refs/heads/dev'
|
||||
|| github.ref == 'refs/heads/main'
|
||||
runs-on: flutter-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-android:36
|
||||
@@ -452,7 +455,7 @@ jobs:
|
||||
# The derivation lives in ci/version.sh, not here, so it can be
|
||||
# executed by a test on every push. Anything inline in this file is
|
||||
# unverifiable until a release is already running.
|
||||
out="$(ci/version.sh HEAD)"
|
||||
out="$(ci/version.sh --apk HEAD)"
|
||||
printf '%s\n' "${out}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The channel is a property of the LANE, not of the commit, which is
|
||||
@@ -601,9 +604,9 @@ jobs:
|
||||
path: release-apk/
|
||||
|
||||
- name: Attach APK to gitea Release
|
||||
# Tag releases only. A dev build has no Release to hang assets on and
|
||||
# does not need one — the :dev image bundles the APK, and the server
|
||||
# serves it from /api/client/apk like any other.
|
||||
# Tag releases only. A branch build has no Release to hang assets on
|
||||
# and does not need one — its image bundles the APK, and the server
|
||||
# serves it from /api/client/apk.
|
||||
shell: bash
|
||||
env:
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
@@ -667,10 +670,9 @@ jobs:
|
||||
|
||||
image-release:
|
||||
name: Build + push container image
|
||||
# Every lane must have SUCCEEDED, each named here. Then the
|
||||
# APK: tag and dev pushes build one and it must have succeeded; main
|
||||
# pushes skip android-release and bundle the latest release's APK
|
||||
# instead, so for main alone a skipped android-release is expected.
|
||||
# Every lane must have SUCCEEDED, each named here, and so must the APK
|
||||
# build: every ref bundles the APK built from its own commit, so an
|
||||
# image without one would move a channel with no app for phones to take.
|
||||
needs: [go, integration, web, android, govulncheck, android-release]
|
||||
if: >-
|
||||
${{
|
||||
@@ -680,8 +682,7 @@ jobs:
|
||||
&& needs.web.result == 'success'
|
||||
&& needs.android.result == 'success'
|
||||
&& needs.govulncheck.result == 'success'
|
||||
&& (needs.android-release.result == 'success'
|
||||
|| (needs.android-release.result == 'skipped' && github.ref == 'refs/heads/main')) }}
|
||||
&& needs.android-release.result == 'success' }}
|
||||
runs-on: go-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||
@@ -747,9 +748,10 @@ jobs:
|
||||
# publish the moving channel tag and never a commit-addressable
|
||||
# one.
|
||||
#
|
||||
# :latest must move here rather than waiting for the next main
|
||||
# push, or the channel would carry the PREVIOUS release's APK
|
||||
# indefinitely — a channel that cannot refresh itself.
|
||||
# :latest moves here so the server hands out the same APK build
|
||||
# that is attached to the Release: a phone that installed from the
|
||||
# Release page and one that updated in-app hold one build, with one
|
||||
# ordering key. Cut a release on main's tip, as :latest follows it.
|
||||
CHANNEL=stable
|
||||
echo "args=-t ${IMAGE}:latest" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Release build ${VERSION}: refreshing :latest around the new APK"
|
||||
@@ -782,12 +784,8 @@ jobs:
|
||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
- name: Download signed APK artifact
|
||||
# Tag and dev pushes — android-release just produced this. Only `main`
|
||||
# takes the "Bundle latest release APK" path below, because it is the
|
||||
# one ref that moves a channel without building an APK of its own.
|
||||
if: >-
|
||||
steps.guard.outputs.ready == 'true' &&
|
||||
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
|
||||
# android-release just produced this, on every ref.
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
# Consuming half of the pair: stock download-artifact, which works here for
|
||||
# the same reason as the upload (gitea/runner 3.x edits the GHES refusal
|
||||
# out of the bundle). v8 runs on node24, which every CI-runner image
|
||||
@@ -798,9 +796,7 @@ jobs:
|
||||
path: client/
|
||||
|
||||
- name: Stage bundled APK + version sidecar
|
||||
if: >-
|
||||
steps.guard.outputs.ready == 'true' &&
|
||||
(startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/dev')
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
# All three pulled from android-release's outputs so the sidecar the
|
||||
@@ -821,65 +817,6 @@ jobs:
|
||||
cat client/minstrel.apk.version
|
||||
ls -lh client/
|
||||
|
||||
- name: Bundle latest release APK (non-tag :latest builds)
|
||||
# Main pushes don't build an APK, but they DO move :latest — so
|
||||
# without this the in-app update channel would vanish from :latest
|
||||
# until the next tag. Pull the most-recent release's signed APK and
|
||||
# the sidecar published beside it, so what the server reports is what
|
||||
# that build actually recorded rather than something re-derived here.
|
||||
# Degrades to an empty client/ (404 update channel) — never a wrong
|
||||
# version — if no release or APK asset can be resolved. That
|
||||
# degradation only actually works because the greps below carry
|
||||
# `|| true`; under the runner's default pipefail a non-matching grep
|
||||
# kills the step instead of falling through to the empty-case branch.
|
||||
if: steps.guard.outputs.ready == 'true' && github.ref == 'refs/heads/main'
|
||||
shell: bash
|
||||
env:
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
REL_JSON="$(curl -fsSL -H "Authorization: token ${CI_TOKEN}" \
|
||||
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/latest" || true)"
|
||||
if [ -z "${REL_JSON}" ]; then
|
||||
echo "::notice::no published release — image ships without bundled APK"; exit 0
|
||||
fi
|
||||
# `|| true` on every one of these, and it is load-bearing rather
|
||||
# than defensive habit. The runner already invokes this shell as
|
||||
# `bash -e -o pipefail`, so a pipeline whose grep matches NOTHING
|
||||
# exits non-zero even though `head` succeeded — and the step dies at
|
||||
# the assignment, before ever reaching the `if` written to handle the
|
||||
# empty case. Every "degrades gracefully" branch below is unreachable
|
||||
# without this.
|
||||
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)" || true
|
||||
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)" || true
|
||||
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
|
||||
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
|
||||
fi
|
||||
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
|
||||
|
||||
# Take the version the release RECORDED rather than recomputing it.
|
||||
# Re-deriving it from the tagged commit would keep the formula in two
|
||||
# files that must stay in step, and could only ever recover the name —
|
||||
# the ordering key is build-time minutes and exists nowhere after that
|
||||
# build ends.
|
||||
SIDECAR_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk\.version$' | head -1)" || true
|
||||
if [ -n "${SIDECAR_URL}" ]; then
|
||||
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk.version "${SIDECAR_URL}"
|
||||
cat client/minstrel.apk.version
|
||||
else
|
||||
# Releases published before sidecars were attached. Their name is
|
||||
# still recoverable from the tag, but their ordering key genuinely
|
||||
# is not — so it is reported ABSENT rather than guessed. A wrong
|
||||
# key is an install the platform refuses; an absent one just tells
|
||||
# the client to fall back to comparing names, which is exactly
|
||||
# what those builds already do.
|
||||
echo "::notice::release ${TAG} predates the version sidecar — bundling with name only, no ordering key"
|
||||
printf '{"name":"%s","code":null,"channel":"stable"}\n' "${TAG#v}" > client/minstrel.apk.version
|
||||
fi
|
||||
echo "::notice::bundled release APK from ${TAG}"
|
||||
ls -lh client/
|
||||
|
||||
- name: Build and push
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
# --pull: the Dockerfile's base images are floating tags (golang:1.26,
|
||||
|
||||
@@ -24,7 +24,6 @@ import com.fabledsword.minstrel.player.LikedAudioPinner
|
||||
import com.fabledsword.minstrel.player.PlayEventsReporter
|
||||
import com.fabledsword.minstrel.player.PlaybackErrorReporter
|
||||
import com.fabledsword.minstrel.player.ResumeController
|
||||
import com.fabledsword.minstrel.update.data.UpdateBannerController
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -155,14 +154,6 @@ class MinstrelApplication :
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var networkStatusController: NetworkStatusController
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — UpdateBannerController polls
|
||||
* /api/client/version at launch + every 24h and drives the shell's
|
||||
* soft "update available" banner. Without this @Inject the poll
|
||||
* loop would never start and the banner would never surface.
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var updateBannerController: UpdateBannerController
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — FreshnessSweeper's init
|
||||
* block starts a 30-min sweep loop that re-fetches cached
|
||||
|
||||
@@ -102,6 +102,8 @@ import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
||||
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
|
||||
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
|
||||
import com.fabledsword.minstrel.update.ui.UpdateNotices
|
||||
import com.fabledsword.minstrel.update.ui.UpdateViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
@@ -553,6 +555,7 @@ class HomeViewModel @Inject constructor(
|
||||
fun HomeScreen(
|
||||
navController: NavHostController,
|
||||
viewModel: HomeViewModel = hiltViewModel(),
|
||||
updates: UpdateViewModel = hiltViewModel(),
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -575,7 +578,11 @@ fun HomeScreen(
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
|
||||
PullToRefreshScaffold(
|
||||
onRefresh = { viewModel.refreshFromPull() },
|
||||
onRefresh = {
|
||||
// A pull asks about a newer app too, so one shows up at once.
|
||||
updates.check()
|
||||
viewModel.refreshFromPull()
|
||||
},
|
||||
modifier = Modifier.fillMaxSize().padding(inner),
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
@@ -590,6 +597,7 @@ fun HomeScreen(
|
||||
// hide behind an "Updating your mixes…" wipe that stays up
|
||||
// until the screen has actually stopped moving.
|
||||
UpdatingVeil(visible = updating)
|
||||
UpdateNotices(updates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ package com.fabledsword.minstrel.models
|
||||
* verbatim rather than validated, so an unexpected value is shown rather
|
||||
* than dropped.
|
||||
*
|
||||
* [apkUrl] is server-relative (e.g. `/api/client/apk`).
|
||||
* [apkUrl] is server-relative (e.g. `/api/client/apk`). [sha256] is the
|
||||
* served file's digest, in hex; empty from a server that predates it, and
|
||||
* then a download is judged whole by its size alone.
|
||||
*/
|
||||
data class UpdateInfo(
|
||||
val version: String,
|
||||
@@ -23,4 +25,5 @@ data class UpdateInfo(
|
||||
val channel: String?,
|
||||
val apkUrl: String,
|
||||
val sizeBytes: Long,
|
||||
val sha256: String,
|
||||
)
|
||||
|
||||
@@ -26,4 +26,5 @@ data class UpdateInfoWire(
|
||||
val channel: String? = null,
|
||||
@SerialName("apk_url") val apkUrl: String = "/api/client/apk",
|
||||
@SerialName("size_bytes") val sizeBytes: Long = 0,
|
||||
val sha256: String = "",
|
||||
)
|
||||
|
||||
+47
-11
@@ -216,24 +216,56 @@ class MinstrelForwardingPlayer(
|
||||
// drops via isLoadingUpnp() = true; the OutputPickerController
|
||||
// observes the uiState.queue change and runs the resync (re-clears
|
||||
// Sonos's native queue + AddURIToQueue the new tracks + Play).
|
||||
//
|
||||
// The SAME tracks set again (play on the album already queued, from
|
||||
// another track) is not a new queue: the renderer already holds it, and
|
||||
// the uiState collector -- which keys on the track ids -- would never see
|
||||
// a change to resync on, leaving the cast stuck "loading". It is a move
|
||||
// within the queue, so the renderer is pointed at the new track and the
|
||||
// play() that follows goes through.
|
||||
|
||||
override fun setMediaItems(mediaItems: List<MediaItem>) {
|
||||
val sameQueue = holdsSameItems(mediaItems)
|
||||
super.setMediaItems(mediaItems)
|
||||
markPendingResyncIfRemote()
|
||||
afterQueueSet(sameQueue)
|
||||
}
|
||||
|
||||
override fun setMediaItems(mediaItems: List<MediaItem>, resetPosition: Boolean) {
|
||||
val sameQueue = holdsSameItems(mediaItems)
|
||||
super.setMediaItems(mediaItems, resetPosition)
|
||||
markPendingResyncIfRemote()
|
||||
afterQueueSet(sameQueue)
|
||||
}
|
||||
|
||||
override fun setMediaItems(mediaItems: List<MediaItem>, startIndex: Int, startPositionMs: Long) {
|
||||
val sameQueue = holdsSameItems(mediaItems)
|
||||
super.setMediaItems(mediaItems, startIndex, startPositionMs)
|
||||
markPendingResyncIfRemote()
|
||||
afterQueueSet(sameQueue)
|
||||
}
|
||||
|
||||
private fun markPendingResyncIfRemote() {
|
||||
val wasActive = holder.active.value ?: return
|
||||
private fun holdsSameItems(mediaItems: List<MediaItem>): Boolean =
|
||||
mediaItems.size == delegate.mediaItemCount &&
|
||||
mediaItems.indices.all { mediaItems[it].mediaId == delegate.getMediaItemAt(it).mediaId }
|
||||
|
||||
private fun afterQueueSet(sameQueue: Boolean) {
|
||||
val active = holder.active.value ?: return
|
||||
if (!sameQueue) {
|
||||
markPendingResync(active)
|
||||
return
|
||||
}
|
||||
val index = delegate.currentMediaItemIndex
|
||||
val positionMs = delegate.currentPosition.coerceAtLeast(0L)
|
||||
Timber.w(
|
||||
"setMediaItems with the queue %s already holds -- moving to track %d",
|
||||
active.routeName,
|
||||
index + 1,
|
||||
)
|
||||
when (active.queueMode) {
|
||||
QueueMode.SINGLE_TRACK -> startSingleTrack(active, index, positionMs)
|
||||
QueueMode.NATIVE_QUEUE -> seekNativeQueue(active, index, positionMs)
|
||||
}
|
||||
}
|
||||
|
||||
private fun markPendingResync(wasActive: ActiveUpnp) {
|
||||
Timber.w(
|
||||
"setMediaItems while UPnP active (%s) -- marking pending resync",
|
||||
wasActive.routeName,
|
||||
@@ -332,11 +364,15 @@ class MinstrelForwardingPlayer(
|
||||
super.seekTo(mediaItemIndex, positionMs)
|
||||
when (active.queueMode) {
|
||||
QueueMode.SINGLE_TRACK -> playSingleTrackAt(active, mediaItemIndex, positionMs)
|
||||
QueueMode.NATIVE_QUEUE -> sendNativeQueueCommand(active) {
|
||||
active.avTransport.seekToTrack(mediaItemIndex + 1)
|
||||
if (positionMs > 0L) {
|
||||
active.avTransport.seek(positionMs)
|
||||
}
|
||||
QueueMode.NATIVE_QUEUE -> seekNativeQueue(active, mediaItemIndex, positionMs)
|
||||
}
|
||||
}
|
||||
|
||||
private fun seekNativeQueue(active: ActiveUpnp, index: Int, positionMs: Long) {
|
||||
sendNativeQueueCommand(active) {
|
||||
active.avTransport.seekToTrack(index + 1)
|
||||
if (positionMs > 0L) {
|
||||
active.avTransport.seek(positionMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -747,7 +783,7 @@ class MinstrelForwardingPlayer(
|
||||
* the library's duration, so the scrubber has an end to run to.
|
||||
*/
|
||||
private fun durationFor(info: PositionInfo, single: SingleTrackLoader.PollOutcome?): Long =
|
||||
if (single != null && info.trackDurationMs <= 0L) singleTrack.knownDurationMs() else info.trackDurationMs
|
||||
if (single != null && info.trackDurationMs <= 0L) single.knownDurationMs else info.trackDurationMs
|
||||
|
||||
/**
|
||||
* Ask the watchdog what to make of this poll, and act on its answer.
|
||||
|
||||
+17
-13
@@ -383,23 +383,27 @@ class OutputPickerController @Inject constructor(
|
||||
resyncSingleTrack(upnpRoute, transport, newQueue, newCurrentIndex)
|
||||
return@withLock
|
||||
}
|
||||
val handledIncrementally = runCatching {
|
||||
// The incremental diff is for a queue changed under a cast that kept
|
||||
// running (play next, an append, a removal): it leaves the playing
|
||||
// track alone. A queue REPLACED through setMediaItems has already
|
||||
// cleared active, and the listener asked for its start track and for
|
||||
// it to play -- both of which the diff would ignore, and with active
|
||||
// never restored the app would think it had gone back to the phone
|
||||
// while the speaker played on. That case reloads in full below.
|
||||
val stillActive = activeUpnpHolder.active.value?.routeId == routeId
|
||||
val handledIncrementally = stillActive && runCatching {
|
||||
sonosQueue.tryIncrementalResync(transport, oldIds, newQueue)
|
||||
}.getOrElse { e ->
|
||||
Timber.w(e, "Sonos incremental resync errored; falling back to full reload")
|
||||
false
|
||||
}
|
||||
if (handledIncrementally) {
|
||||
// Active was never cleared on the incremental path; clear any
|
||||
// target that markPendingResyncIfRemote set (it didn't, for
|
||||
// incremental cases that don't go through setMediaItems, but
|
||||
// belt-and-suspenders).
|
||||
activeUpnpHolder.setTarget(null)
|
||||
return@withLock
|
||||
}
|
||||
// Full rebuild: ensure active is cleared so transport calls drop
|
||||
// (markPendingResyncIfRemote may already have done this on the
|
||||
// setMediaItems path).
|
||||
// (the forwarding player has already done this on the setMediaItems
|
||||
// path).
|
||||
if (activeUpnpHolder.active.value != null) {
|
||||
activeUpnpHolder.set(null)
|
||||
activeUpnpHolder.setTarget(routeId)
|
||||
@@ -679,11 +683,11 @@ class OutputPickerController @Inject constructor(
|
||||
localQueue: List<TrackRef>,
|
||||
localIndex: Int,
|
||||
): Boolean {
|
||||
val (transport, rendering, route, queueMode) = target
|
||||
val route = target.route
|
||||
val plan = probeAdoptable(target, localQueue, localIndex) ?: return false
|
||||
if (queueMode == QueueMode.SINGLE_TRACK) {
|
||||
if (target.queueMode == QueueMode.SINGLE_TRACK) {
|
||||
// The renderer holds only this track; arm the one after it.
|
||||
singleTrack.adopt(transport, localQueue, plan.localIndex, plan.trackUri)
|
||||
singleTrack.adopt(target.transport, localQueue, plan.localIndex, plan.trackUri)
|
||||
}
|
||||
Timber.w(
|
||||
"UPnP adopt: attaching to live session on %s at track %d (%dms)",
|
||||
@@ -704,9 +708,9 @@ class OutputPickerController @Inject constructor(
|
||||
ActiveUpnp(
|
||||
routeId = route.id,
|
||||
routeName = route.name,
|
||||
avTransport = transport,
|
||||
rendering = rendering,
|
||||
queueMode = queueMode,
|
||||
avTransport = target.transport,
|
||||
rendering = target.rendering,
|
||||
queueMode = target.queueMode,
|
||||
),
|
||||
)
|
||||
return true
|
||||
|
||||
+11
-10
@@ -70,8 +70,10 @@ class SingleTrackLoader @Inject constructor(
|
||||
* renderer is playing (0 when nothing is loaded). [advanced] is true when
|
||||
* this poll started the next track itself, so the reading it was given is
|
||||
* already stale and the caller should not judge a stall from it.
|
||||
* [knownDurationMs] is the library's duration for that track, for a
|
||||
* renderer that reports none.
|
||||
*/
|
||||
data class PollOutcome(val trackNumber: Int, val advanced: Boolean)
|
||||
data class PollOutcome(val trackNumber: Int, val advanced: Boolean, val knownDurationMs: Long = 0L)
|
||||
|
||||
/** Take [queue] as the one to play, and start [index] on the renderer. */
|
||||
suspend fun load(transport: AVTransportClient, queue: List<TrackRef>, index: Int) =
|
||||
@@ -137,9 +139,8 @@ class SingleTrackLoader @Inject constructor(
|
||||
if (currentIndex !in queue.indices) return@withLock PollOutcome(0, advanced = false)
|
||||
val moved = followRenderer(trackUri)
|
||||
armNext(transport)
|
||||
if (moved) return@withLock PollOutcome(currentIndex + 1, advanced = false)
|
||||
val known = queue[currentIndex].durationSec * MILLIS_PER_SECOND
|
||||
endedOnLastPoll = endDetector.onPoll(state, positionMs, durationMs, known)
|
||||
if (moved) return@withLock PollOutcome(currentIndex + 1, advanced = false, knownDurationMs(queue, currentIndex))
|
||||
endedOnLastPoll = endDetector.onPoll(state, positionMs, durationMs, knownDurationMs(queue, currentIndex))
|
||||
val next = currentIndex + 1
|
||||
val advance = endedOnLastPoll && playIntent && next in queue.indices
|
||||
if (advance) {
|
||||
@@ -147,7 +148,7 @@ class SingleTrackLoader @Inject constructor(
|
||||
runCatching { start(transport, next) }
|
||||
.onFailure { Timber.w(it, "UPnP single-track: could not start track %d", next + 1) }
|
||||
}
|
||||
PollOutcome(currentIndex + 1, advanced = advance)
|
||||
PollOutcome(currentIndex + 1, advanced = advance, knownDurationMs(queue, currentIndex))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,10 +163,6 @@ class SingleTrackLoader @Inject constructor(
|
||||
RemoteStallWatchdog.QueueState.UNKNOWN
|
||||
}
|
||||
|
||||
/** The library's duration for the current track, for a renderer that reports none. */
|
||||
fun knownDurationMs(): Long =
|
||||
queue.getOrNull(currentIndex)?.durationSec?.times(MILLIS_PER_SECOND) ?: 0L
|
||||
|
||||
private suspend fun start(transport: AVTransportClient, index: Int): Boolean {
|
||||
if (index !in queue.indices) return false
|
||||
val token = mint(index)
|
||||
@@ -240,11 +237,14 @@ class SingleTrackLoader @Inject constructor(
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val MILLIS_PER_SECOND = 1_000L
|
||||
const val ARM_RETRY_POLLS = 10
|
||||
}
|
||||
}
|
||||
|
||||
/** The library's duration of [queue]'s track at [index], or 0 when there is none. */
|
||||
private fun knownDurationMs(queue: List<TrackRef>, index: Int): Long =
|
||||
queue.getOrNull(index)?.durationSec?.times(MILLIS_PER_SECOND) ?: 0L
|
||||
|
||||
/**
|
||||
* Index in [queue] of the track with [id], taking the occurrence nearest
|
||||
* [preferNear] so a track queued twice does not send the cursor across the
|
||||
@@ -263,3 +263,4 @@ internal fun streamUriTrackId(uri: String): String? {
|
||||
}
|
||||
|
||||
private const val TRACKS_PATH_MARKER = "/api/tracks/"
|
||||
private const val MILLIS_PER_SECOND = 1_000L
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
package com.fabledsword.minstrel.settings.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.models.UpdateInfo
|
||||
import com.fabledsword.minstrel.update.data.ApkInstaller
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.UpdateRepository
|
||||
import com.fabledsword.minstrel.update.data.isBusy
|
||||
import com.fabledsword.minstrel.update.data.isUpdateAvailable
|
||||
import com.fabledsword.minstrel.update.data.message
|
||||
import com.fabledsword.minstrel.update.data.stage
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* One of three terminal states the Check-for-updates button surfaces.
|
||||
* `Idle` is the pre-check state; `Latest` means the installed build
|
||||
* matches or exceeds the server's bundled APK; `UpdateAvailable`
|
||||
* surfaces an "Install vX.Y.Z" button that downloads the APK and
|
||||
* installs it via [ApkInstaller].
|
||||
*/
|
||||
sealed interface UpdateCheckResult {
|
||||
data object Idle : UpdateCheckResult
|
||||
data object Latest : UpdateCheckResult
|
||||
data class UpdateAvailable(val info: UpdateInfo) : UpdateCheckResult
|
||||
data class Error(val message: String) : UpdateCheckResult
|
||||
}
|
||||
|
||||
data class AboutUiState(
|
||||
val installedVersion: String = BuildConfig.VERSION_NAME,
|
||||
// The value the platform installs by, and therefore the one the update
|
||||
// check must decide on. Held in state rather than read inline so a test
|
||||
// can drive the comparison without a BuildConfig.
|
||||
val installedCode: Long = BuildConfig.VERSION_CODE.toLong(),
|
||||
// A debug build is signed with this machine's debug key, so the server's
|
||||
// release-signed APK can never install over it. It updates from Android
|
||||
// Studio instead.
|
||||
val selfUpdates: Boolean = !BuildConfig.DEBUG,
|
||||
val isChecking: Boolean = false,
|
||||
val installStage: InstallStage = InstallStage.IDLE,
|
||||
val installMessage: String? = null,
|
||||
val result: UpdateCheckResult = UpdateCheckResult.Idle,
|
||||
)
|
||||
|
||||
/**
|
||||
* Backs the About card's update controls. "Check for updates" calls
|
||||
* [UpdateRepository.getLatest], compares versus this build via
|
||||
* [isUpdateAvailable] — on the ordering key where the server reports one,
|
||||
* on the name otherwise — and reports the terminal state.
|
||||
* When an update is available, [install] downloads the APK via
|
||||
* [ApkInstaller] and installs it — routing the user to the "install
|
||||
* unknown apps" settings page first when that permission hasn't been
|
||||
* granted.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AboutCardViewModel @Inject constructor(
|
||||
private val repository: UpdateRepository,
|
||||
private val installer: ApkInstaller,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow(AboutUiState())
|
||||
val state: StateFlow<AboutUiState> = internal.asStateFlow()
|
||||
|
||||
fun checkForUpdates() {
|
||||
if (internal.value.isChecking || !internal.value.selfUpdates) return
|
||||
viewModelScope.launch {
|
||||
internal.update { it.copy(isChecking = true, installMessage = null) }
|
||||
val installed = internal.value.installedVersion
|
||||
val installedCode = internal.value.installedCode
|
||||
val result = runCatching { repository.getLatest() }
|
||||
.map { latest ->
|
||||
if (
|
||||
isUpdateAvailable(
|
||||
serverCode = latest.code,
|
||||
serverName = latest.version,
|
||||
installedCode = installedCode,
|
||||
installedName = installed,
|
||||
)
|
||||
) {
|
||||
UpdateCheckResult.UpdateAvailable(latest)
|
||||
} else {
|
||||
UpdateCheckResult.Latest
|
||||
}
|
||||
}
|
||||
.getOrElse { e -> UpdateCheckResult.Error(ErrorCopy.fromThrowable(e)) }
|
||||
internal.update { it.copy(isChecking = false, result = result) }
|
||||
}
|
||||
}
|
||||
|
||||
fun install(info: UpdateInfo) {
|
||||
if (internal.value.installStage.isBusy()) return
|
||||
if (!installer.canInstall()) {
|
||||
installer.requestInstallPermission()
|
||||
internal.update {
|
||||
it.copy(installMessage = "Allow installs for Minstrel, then tap Install again.")
|
||||
}
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
internal.update {
|
||||
it.copy(installStage = InstallStage.DOWNLOADING, installMessage = null)
|
||||
}
|
||||
val apk = download(info.apkUrl)
|
||||
if (apk != null) {
|
||||
// The install half now suspends on the platform's verdict, so it
|
||||
// gets its own stage — reporting "Downloading…" through it would
|
||||
// be a lie once a confirm dialog is on screen.
|
||||
internal.update { it.copy(installStage = InstallStage.INSTALLING) }
|
||||
val outcome = installer.install(apk)
|
||||
internal.update {
|
||||
it.copy(installStage = outcome.stage(), installMessage = outcome.message())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun download(apkUrl: String): File? =
|
||||
runCatching { installer.downloadApk(apkUrl) }
|
||||
.onFailure { e ->
|
||||
internal.update {
|
||||
it.copy(
|
||||
installStage = InstallStage.ERROR,
|
||||
installMessage = "Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
|
||||
)
|
||||
}
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
@@ -51,7 +51,6 @@ import com.composables.icons.lucide.ListMusic
|
||||
import com.composables.icons.lucide.LogOut
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Shield
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.nav.Admin
|
||||
import com.fabledsword.minstrel.nav.NotificationSettings
|
||||
import com.fabledsword.minstrel.nav.Requests
|
||||
@@ -61,8 +60,9 @@ import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
import com.fabledsword.minstrel.theme.ThemeMode
|
||||
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.isBusy
|
||||
import com.fabledsword.minstrel.update.ui.AppVersionCard
|
||||
import com.fabledsword.minstrel.update.ui.CheckWhileShown
|
||||
import com.fabledsword.minstrel.update.ui.UpdateViewModel
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
@@ -367,112 +367,14 @@ private fun displayName(mode: ThemeMode): String = when (mode) {
|
||||
ThemeMode.DARK -> "Dark"
|
||||
}
|
||||
|
||||
/**
|
||||
* The About card is the app-version card Home's update sheet holds too; on
|
||||
* Settings it also asks the server again while the screen is up.
|
||||
*/
|
||||
@Composable
|
||||
private fun AboutCard(viewModel: AboutCardViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "About",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = "Installed: Minstrel ${state.installedVersion} " +
|
||||
"(${BuildConfig.VERSION_CODE})",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
UpdateControls(state = state, viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
||||
if (!state.selfUpdates) {
|
||||
Text(
|
||||
text = "Debug build: updates install from Android Studio.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
return
|
||||
}
|
||||
UpdateCheckLine(result = state.result)
|
||||
Button(
|
||||
onClick = viewModel::checkForUpdates,
|
||||
enabled = !state.isChecking && !state.installStage.isBusy(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (state.isChecking) {
|
||||
ButtonSpinner()
|
||||
}
|
||||
Text(if (state.isChecking) "Checking…" else "Check for updates")
|
||||
}
|
||||
val available = state.result as? UpdateCheckResult.UpdateAvailable
|
||||
if (available != null) {
|
||||
InstallButton(
|
||||
version = available.info.version,
|
||||
stage = state.installStage,
|
||||
onClick = { viewModel.install(available.info) },
|
||||
)
|
||||
}
|
||||
if (state.installMessage != null) {
|
||||
Text(
|
||||
text = state.installMessage,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InstallButton(version: String, stage: InstallStage, onClick: () -> Unit) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = !stage.isBusy(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (stage.isBusy()) {
|
||||
ButtonSpinner()
|
||||
}
|
||||
Text(
|
||||
when (stage) {
|
||||
InstallStage.DOWNLOADING -> "Downloading…"
|
||||
InstallStage.INSTALLING -> "Installing…"
|
||||
else -> "Install $version"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ButtonSpinner() {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(16.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UpdateCheckLine(result: UpdateCheckResult) {
|
||||
val text = when (result) {
|
||||
UpdateCheckResult.Idle -> return
|
||||
UpdateCheckResult.Latest -> "You're on the latest version."
|
||||
is UpdateCheckResult.UpdateAvailable ->
|
||||
"Update available: ${result.info.version}"
|
||||
is UpdateCheckResult.Error -> "Couldn't check: ${result.message}"
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
private fun AboutCard(model: UpdateViewModel = hiltViewModel()) {
|
||||
CheckWhileShown(model)
|
||||
AppVersionCard(model)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -246,7 +246,7 @@ private fun ClearCacheConfirmDialog(onConfirm: () -> Unit, onDismiss: () -> Unit
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatBytes(n: Long): String {
|
||||
internal fun formatBytes(n: Long): String {
|
||||
if (n <= 0L) return "0 B"
|
||||
return when {
|
||||
n < KIB -> "$n B"
|
||||
|
||||
@@ -21,7 +21,6 @@ import com.fabledsword.minstrel.cache.mutations.OfflineWriteHintViewModel
|
||||
import com.fabledsword.minstrel.connectivity.ui.ConnectionErrorBanner
|
||||
import com.fabledsword.minstrel.player.ui.MiniPlayer
|
||||
import com.fabledsword.minstrel.player.ui.PlaybackErrorViewModel
|
||||
import com.fabledsword.minstrel.update.ui.UpdateBanner
|
||||
import com.fabledsword.minstrel.update.ui.VersionTooOldBanner
|
||||
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
|
||||
|
||||
@@ -63,7 +62,7 @@ val ShellContentWindowInsets: WindowInsets = WindowInsets(0, 0, 0, 0)
|
||||
* [onExpandPlayer] is the navigate-to-NowPlaying callback passed
|
||||
* through to MiniPlayer's tap-handler. No NavHostController parameter
|
||||
* because nothing inside the shell currently navigates - if a banner
|
||||
* (UpdateBanner / VersionTooOldBanner / ConnectionErrorBanner) ever
|
||||
* (VersionTooOldBanner / ConnectionErrorBanner) ever
|
||||
* grows tap-to-navigate, plumb a NavHostController in at that point.
|
||||
*/
|
||||
@Composable
|
||||
@@ -97,12 +96,10 @@ fun ShellScaffold(
|
||||
// the status bar — no double inset, and no banner drawing under it.
|
||||
Column(modifier = modifier.fillMaxSize().statusBarsPadding()) {
|
||||
// Banner slot. VersionTooOldBanner appears when the server's
|
||||
// min_client_version exceeds our build; UpdateBanner softly
|
||||
// nudges an available (newer-than-installed) APK; and
|
||||
// ConnectionErrorBanner shows when the device loses usable
|
||||
// internet.
|
||||
// min_client_version exceeds our build, and ConnectionErrorBanner
|
||||
// shows when the device loses usable internet. A newer app is not
|
||||
// a banner: Home tells of it in a snackbar (UpdateNotices).
|
||||
VersionTooOldBanner()
|
||||
UpdateBanner()
|
||||
ConnectionErrorBanner()
|
||||
// The window is edge-to-edge, so something has to own the
|
||||
// bottom navigation-bar inset, and the shell claims it once
|
||||
|
||||
@@ -6,24 +6,13 @@ import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val APK_FILENAME = "minstrel-update.apk"
|
||||
|
||||
/**
|
||||
* Downloads the server-bundled APK and installs it over ourselves.
|
||||
*
|
||||
* The download goes through the shared [OkHttpClient] so it inherits
|
||||
* the auth cookie + the BaseUrlInterceptor host rewrite (apkUrl is
|
||||
* server-relative, e.g. `/api/client/apk`). The APK lands in the
|
||||
* cache dir; [SelfUpdateSession] streams it from there into a
|
||||
* Installs a downloaded APK over ourselves. [UpdateRepository] brings it
|
||||
* down; [SelfUpdateSession] streams it from there into a
|
||||
* [android.content.pm.PackageInstaller] session.
|
||||
*
|
||||
* On Android O+ the user must have granted "install unknown apps"
|
||||
@@ -34,26 +23,8 @@ private const val APK_FILENAME = "minstrel-update.apk"
|
||||
@Singleton
|
||||
class ApkInstaller @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val session: SelfUpdateSession,
|
||||
) {
|
||||
suspend fun downloadApk(apkUrl: String): File = withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder()
|
||||
.url("http://placeholder.invalid$apkUrl")
|
||||
.build()
|
||||
okHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Download failed: HTTP ${response.code}")
|
||||
}
|
||||
val body = response.body ?: throw IOException("Empty download body")
|
||||
val file = File(context.cacheDir, APK_FILENAME)
|
||||
body.byteStream().use { input ->
|
||||
file.outputStream().use { output -> input.copyTo(output) }
|
||||
}
|
||||
file
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the system will accept an install intent without a permission detour. */
|
||||
fun canInstall(): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.models.UpdateInfo
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class UpdateUiState(
|
||||
/** Null when there is nothing newer to offer. */
|
||||
val offer: UpdateInfo? = null,
|
||||
val stage: InstallStage = InstallStage.IDLE,
|
||||
val progress: Float = 0f,
|
||||
val problem: String? = null,
|
||||
/** Asking the server which app it offers. */
|
||||
val checking: Boolean = false,
|
||||
/** Why the last ask got no answer, when it did not. */
|
||||
val unchecked: String? = null,
|
||||
/** The offer is already downloaded and checked: Install is all that is left. */
|
||||
val ready: Boolean = false,
|
||||
)
|
||||
|
||||
/** A newer build to tell the listener of, and whether it is already here to install. */
|
||||
data class UpdateNews(val offer: UpdateInfo, val ready: Boolean)
|
||||
|
||||
/**
|
||||
* The one record of whether a newer app is on offer and where installing it
|
||||
* has got to, shared by Home (which tells of it) and the About card (in
|
||||
* Settings, and in the sheet Home's news opens). A singleton because each
|
||||
* screen gets its own view model, and an install started from one must
|
||||
* show on the other.
|
||||
*
|
||||
* Only the platform's answer retires an offer: declined, failed or refused,
|
||||
* it stays on screen. A newer build is fetched ahead on Wi-Fi
|
||||
* ([UpdateScheduler]), so the listener is usually told it is ready and
|
||||
* Install is one tap. Home is told at most once a day for each build
|
||||
* ([UpdateTold]).
|
||||
*/
|
||||
@Singleton
|
||||
class AppUpdates @Inject constructor(
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
private val updates: UpdateRepository,
|
||||
private val installer: ApkInstaller,
|
||||
private val told: UpdateTold,
|
||||
private val fetching: UpdateScheduler,
|
||||
) {
|
||||
private val mutable = MutableStateFlow(UpdateUiState())
|
||||
val state: StateFlow<UpdateUiState> = mutable.asStateFlow()
|
||||
|
||||
private val telling = Channel<UpdateNews>(Channel.BUFFERED)
|
||||
|
||||
/** A newer build to tell of, once; a screen that shows it calls [tellHere] first. */
|
||||
val news: Flow<UpdateNews> = telling.receiveAsFlow()
|
||||
|
||||
private var asking: Job? = null
|
||||
private var tells = false
|
||||
|
||||
/**
|
||||
* Asks the server which app it offers: when Home or Settings opens, on a
|
||||
* pull, and every few minutes while one is up. A newer one being fetched
|
||||
* or installed is never taken off the screen by a later answer.
|
||||
*/
|
||||
fun check(): Job {
|
||||
asking?.takeIf { it.isActive }?.let { return it }
|
||||
return scope.launch { refresh() }.also { asking = it }
|
||||
}
|
||||
|
||||
private suspend fun refresh() {
|
||||
mutable.update { it.copy(checking = true) }
|
||||
val offer = updates.check()
|
||||
mutable.update { now ->
|
||||
val busy = now.stage != InstallStage.IDLE
|
||||
when (offer) {
|
||||
is Offer.Newer -> now.copy(offer = if (busy) now.offer else offer.info, unchecked = null)
|
||||
Offer.Current -> now.copy(offer = if (busy) now.offer else null, unchecked = null)
|
||||
is Offer.Problem -> now.copy(unchecked = offer.message)
|
||||
}.copy(checking = false)
|
||||
}
|
||||
fetchOrTidy(offer)
|
||||
lookForReady()
|
||||
tellIfDue()
|
||||
}
|
||||
|
||||
/** A newer build is fetched ahead; once none is offered, a package kept for one is only space. */
|
||||
private suspend fun fetchOrTidy(offer: Offer) {
|
||||
if (mutable.value.stage != InstallStage.IDLE) return
|
||||
when (offer) {
|
||||
is Offer.Newer -> fetching.fetchAhead()
|
||||
Offer.Current -> updates.tidy()
|
||||
is Offer.Problem -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
/** Home is up: what is on offer now is told, if it is due. */
|
||||
fun tellHere() {
|
||||
tells = true
|
||||
tellIfDue()
|
||||
}
|
||||
|
||||
private fun tellIfDue() {
|
||||
val now = mutable.value
|
||||
val offer = now.offer
|
||||
if (!tells || offer == null || now.stage != InstallStage.IDLE) return
|
||||
val key = buildKey(offer.code, offer.version)
|
||||
val at = System.currentTimeMillis()
|
||||
if (dueToSay(key, told.build(), told.at(), at)) {
|
||||
told.told(key, at)
|
||||
telling.trySend(UpdateNews(offer, now.ready))
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the offer has been downloaded meanwhile, perhaps in the background. */
|
||||
fun lookForReady() {
|
||||
mutable.update { it.copy(ready = it.offer?.let(updates::ready) != null) }
|
||||
}
|
||||
|
||||
fun mayInstall(): Boolean = installer.canInstall()
|
||||
|
||||
/** Sends the listener to allow installs; the update carries on when they come back. */
|
||||
fun askToInstall() {
|
||||
mutable.update { it.copy(problem = ALLOW) }
|
||||
installer.requestInstallPermission()
|
||||
}
|
||||
|
||||
/** Came back from allowing installs without allowing them. */
|
||||
fun refused() {
|
||||
mutable.update { it.copy(problem = REFUSED) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the offer (or takes the one already here) and installs it.
|
||||
* Runs in the app's scope, so leaving the screen does not stop it.
|
||||
*/
|
||||
fun fetchAndInstall() {
|
||||
val offer = mutable.value.offer ?: return
|
||||
if (mutable.value.stage != InstallStage.IDLE) return
|
||||
mutable.update { it.copy(stage = InstallStage.DOWNLOADING, progress = 0f, problem = null) }
|
||||
scope.launch {
|
||||
val download = updates.download(offer) { done -> mutable.update { it.copy(progress = done) } }
|
||||
when (download) {
|
||||
is Download.Problem -> mutable.update {
|
||||
it.copy(stage = InstallStage.IDLE, problem = download.message)
|
||||
}
|
||||
is Download.Ready -> {
|
||||
mutable.update { it.copy(ready = true) }
|
||||
install(download.apk)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun install(apk: File) {
|
||||
mutable.update { it.copy(stage = InstallStage.INSTALLING) }
|
||||
// On a successful silent install this never returns: the process is replaced.
|
||||
val outcome = installer.install(apk)
|
||||
mutable.update { it.copy(stage = InstallStage.IDLE, problem = outcome.message()) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val ALLOW = "Allow Minstrel to install apps. The update carries on when you come back."
|
||||
const val REFUSED =
|
||||
"Minstrel is still not allowed to install apps, so nothing was installed. Tap Update to try again."
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What coming back to the app does to an update the listener asked for and
|
||||
* was sent to allow installs for.
|
||||
*/
|
||||
internal enum class AfterAsking {
|
||||
/** Allowed now: carry on with the update they already asked for. */
|
||||
FETCH,
|
||||
|
||||
/** Came back without allowing it. The offer stays; they are told. */
|
||||
REFUSED,
|
||||
|
||||
/** Nothing asked, something already under way, or the offer not known yet. */
|
||||
WAIT,
|
||||
}
|
||||
|
||||
internal fun afterAsking(asked: Boolean, mayInstall: Boolean, offered: Boolean, busy: Boolean): AfterAsking =
|
||||
when {
|
||||
!asked || busy || !offered -> AfterAsking.WAIT
|
||||
mayInstall -> AfterAsking.FETCH
|
||||
else -> AfterAsking.REFUSED
|
||||
}
|
||||
@@ -26,37 +26,22 @@ sealed interface InstallOutcome {
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an install has got to, for the two surfaces that show it: the shell's
|
||||
* [UpdateBanner][com.fabledsword.minstrel.update.ui.UpdateBanner] and the
|
||||
* Settings About card.
|
||||
* Where an install has got to, for the card that shows it (in Settings and
|
||||
* in the sheet Home's update news opens).
|
||||
*
|
||||
* DOWNLOADING and INSTALLING are deliberately distinct: the install awaits
|
||||
* the platform's verdict and can sit on a confirm dialog indefinitely, so
|
||||
* one combined state would leave the UI claiming "Downloading…" through it.
|
||||
* A failure is not a stage: it returns to IDLE with the reason beside it,
|
||||
* and the offer stays so the listener can try again.
|
||||
*/
|
||||
enum class InstallStage { IDLE, DOWNLOADING, INSTALLING, ERROR }
|
||||
|
||||
/** True while an install is underway and a second tap should do nothing. */
|
||||
fun InstallStage.isBusy(): Boolean =
|
||||
this == InstallStage.DOWNLOADING || this == InstallStage.INSTALLING
|
||||
|
||||
/**
|
||||
* The stage an outcome lands the UI in. A cancelled install returns to IDLE
|
||||
* rather than ERROR — the user chose it, so presenting it as a failure would
|
||||
* be a lie with a red tint.
|
||||
*/
|
||||
fun InstallOutcome.stage(): InstallStage = when (this) {
|
||||
InstallOutcome.Installed, InstallOutcome.Cancelled -> InstallStage.IDLE
|
||||
is InstallOutcome.Failed -> InstallStage.ERROR
|
||||
}
|
||||
enum class InstallStage { IDLE, DOWNLOADING, INSTALLING }
|
||||
|
||||
/**
|
||||
* User-facing copy for an outcome; null when there is nothing worth saying.
|
||||
*
|
||||
* Lives beside the outcome rather than in either UI package because two
|
||||
* separate screens surface the same verdicts and must not drift — the same
|
||||
* reasoning that puts [ErrorCopy][com.fabledsword.minstrel.api.ErrorCopy]
|
||||
* outside the UI layer.
|
||||
* Lives beside the outcome rather than in the UI package, the same reasoning
|
||||
* that puts [ErrorCopy][com.fabledsword.minstrel.api.ErrorCopy] outside the
|
||||
* UI layer.
|
||||
*/
|
||||
fun InstallOutcome.message(): String? = when (this) {
|
||||
InstallOutcome.Installed -> null
|
||||
|
||||
-78
@@ -1,78 +0,0 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.models.UpdateInfo
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val POLL_INTERVAL_MS = 24 * 60 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* Drives the shell's soft "update available" banner. Polls
|
||||
* `/api/client/version` at launch + every 24h and, when the bundled
|
||||
* APK outranks this build — by ordering key where the server reports one,
|
||||
* by name otherwise — exposes its [UpdateInfo] so
|
||||
* [com.fabledsword.minstrel.update.ui.UpdateBanner] can nudge an
|
||||
* install.
|
||||
*
|
||||
* Dismissals are kept in memory and keyed by version, so dismissing
|
||||
* one release's banner still lets a later release re-surface — and a
|
||||
* restart re-shows it, which is acceptable nudging. Server 404 /
|
||||
* network errors stay silent. Constructed at
|
||||
* launch via the construct-the-singleton trick in `MinstrelApplication`.
|
||||
*
|
||||
* A debug build never polls: it is signed with a local debug key, so the
|
||||
* server's release-signed APK could never install over it.
|
||||
*/
|
||||
@Singleton
|
||||
class UpdateBannerController @Inject constructor(
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
private val repository: UpdateRepository,
|
||||
) {
|
||||
private val latest = MutableStateFlow<UpdateInfo?>(null)
|
||||
private val dismissed = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
/** The available update, or null when none / dismissed / unreachable. */
|
||||
val available: StateFlow<UpdateInfo?> =
|
||||
combine(latest, dismissed) { info, seen ->
|
||||
info?.takeIf { it.version !in seen }
|
||||
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
|
||||
init {
|
||||
if (!BuildConfig.DEBUG) startPolling()
|
||||
}
|
||||
|
||||
private fun startPolling() {
|
||||
scope.launch {
|
||||
while (true) {
|
||||
runOnce()
|
||||
delay(POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun dismiss(version: String) {
|
||||
dismissed.value = dismissed.value + version
|
||||
}
|
||||
|
||||
private suspend fun runOnce() {
|
||||
val info = runCatching { repository.getLatest() }.getOrNull() ?: return
|
||||
latest.value = info.takeIf {
|
||||
isUpdateAvailable(
|
||||
serverCode = it.code,
|
||||
serverName = it.version,
|
||||
installedCode = BuildConfig.VERSION_CODE.toLong(),
|
||||
installedName = BuildConfig.VERSION_NAME,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.hilt.work.HiltWorker
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Brings the newer app down before the listener is asked to install it, so
|
||||
* Install is one tap rather than a wait. It never installs and shows
|
||||
* nothing: whether to update stays the listener's.
|
||||
*
|
||||
* It asks the server again rather than being told what to fetch. WorkManager
|
||||
* may run it hours after it was asked for, when Wi-Fi comes, and by then the
|
||||
* server may offer another build.
|
||||
*/
|
||||
@HiltWorker
|
||||
class UpdateFetchWorker @AssistedInject constructor(
|
||||
@Assisted context: Context,
|
||||
@Assisted params: WorkerParameters,
|
||||
private val updates: UpdateRepository,
|
||||
) : CoroutineWorker(context, params) {
|
||||
override suspend fun doWork(): Result = when (val offer = updates.check()) {
|
||||
// A server out of reach is a phone away from home; the next check asks for this again.
|
||||
is Offer.Problem -> Result.success()
|
||||
Offer.Current -> {
|
||||
updates.tidy()
|
||||
Result.success()
|
||||
}
|
||||
is Offer.Newer -> when (updates.download(offer.info) {}) {
|
||||
is Download.Ready -> Result.success()
|
||||
// A dropped connection is worth another go after WorkManager's
|
||||
// backoff, which also outlasts the server's one-download-a-minute
|
||||
// limit; a few, not forever.
|
||||
is Download.Problem -> if (runAttemptCount < TRIES) Result.retry() else Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TRIES = 3
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the platform to fetch the newer app when it is free to: on an
|
||||
* unmetered network, with the battery and storage not low. Nothing turns
|
||||
* this onto mobile data, because the listener's Update still downloads at
|
||||
* once over whatever network there is; this only saves the wait.
|
||||
*
|
||||
* Kept rather than replaced, so a check every few minutes never restarts a
|
||||
* download already under way.
|
||||
*/
|
||||
@Singleton
|
||||
class UpdateScheduler @Inject constructor(@ApplicationContext private val context: Context) {
|
||||
fun fetchAhead() {
|
||||
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||
FETCHING,
|
||||
ExistingWorkPolicy.KEEP,
|
||||
OneTimeWorkRequestBuilder<UpdateFetchWorker>()
|
||||
.setConstraints(
|
||||
Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.UNMETERED)
|
||||
.setRequiresBatteryNotLow(true)
|
||||
.setRequiresStorageNotLow(true)
|
||||
.build(),
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FETCHING = "update-fetch"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
import android.content.Context
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.update.api.ClientVersionApi
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import retrofit2.Retrofit
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object UpdateModule {
|
||||
/**
|
||||
* The download goes through the shared [OkHttpClient] so it inherits the
|
||||
* auth cookie and the BaseUrlInterceptor host rewrite (apkUrl is
|
||||
* server-relative, e.g. `/api/client/apk`). A refusal, such as the
|
||||
* server's one-download-a-minute limit, reads as an IOException, which
|
||||
* the repository reports and the fetch worker retries after a backoff.
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideUpdateRepository(
|
||||
@ApplicationContext context: Context,
|
||||
retrofit: Retrofit,
|
||||
okHttpClient: OkHttpClient,
|
||||
): UpdateRepository = UpdateRepository(
|
||||
api = retrofit.create(ClientVersionApi::class.java),
|
||||
apk = ApkSource { apkUrl ->
|
||||
withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder().url("http://placeholder.invalid$apkUrl").build()
|
||||
val response = okHttpClient.newCall(request).execute()
|
||||
if (!response.isSuccessful) {
|
||||
response.close()
|
||||
throw IOException("Download failed: HTTP ${response.code}")
|
||||
}
|
||||
response.body ?: throw IOException("Empty download body")
|
||||
}
|
||||
},
|
||||
dir = File(context.cacheDir, "update"),
|
||||
installedCode = BuildConfig.VERSION_CODE.toLong(),
|
||||
installedName = BuildConfig.VERSION_NAME,
|
||||
offers = !BuildConfig.DEBUG,
|
||||
legacyApk = File(context.cacheDir, "minstrel-update.apk"),
|
||||
)
|
||||
}
|
||||
+191
-19
@@ -1,21 +1,158 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.models.UpdateInfo
|
||||
import com.fabledsword.minstrel.models.wire.UpdateInfoWire
|
||||
import com.fabledsword.minstrel.update.api.ClientVersionApi
|
||||
import retrofit2.Retrofit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerializationException
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.HttpException
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.HttpURLConnection.HTTP_NOT_FOUND
|
||||
import java.security.MessageDigest
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/** What asking the server for its app came to. */
|
||||
sealed interface Offer {
|
||||
/** The server's app is another build than this one, and installs over it. */
|
||||
data class Newer(val info: UpdateInfo) : Offer
|
||||
|
||||
/** This is the server's app, or the same app built again, or the server has none to offer. */
|
||||
data object Current : Offer
|
||||
|
||||
/** The server could not be asked. [message] says why, for the About card. */
|
||||
data class Problem(val message: String) : Offer
|
||||
}
|
||||
|
||||
/** What a download came to. */
|
||||
sealed interface Download {
|
||||
data class Ready(val apk: File) : Download
|
||||
data class Problem(val message: String) : Download
|
||||
}
|
||||
|
||||
/** Opens the server's APK for reading. The caller closes the body. */
|
||||
fun interface ApkSource {
|
||||
suspend fun open(apkUrl: String): ResponseBody
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin facade over `/api/client/version`, used by the About card's
|
||||
* Check for updates button and by [UpdateBannerController].
|
||||
* Whether the server offers a newer app than this one, and fetching it.
|
||||
*
|
||||
* One way a package arrives, whether [UpdateFetchWorker] brought it down
|
||||
* ahead on Wi-Fi or the listener pressed Update: [download]. The two take
|
||||
* turns, and each looks first for what the other already brought, so
|
||||
* nothing is downloaded twice. A package only gets its build's name once
|
||||
* it checked out against the size and digest the server gave, so a file at
|
||||
* that name is that build, whole ([ready]).
|
||||
*
|
||||
* A debug build is never offered one ([offers] false): it is signed with the
|
||||
* debug key, so the platform would refuse the release build over it anyway.
|
||||
*/
|
||||
@Singleton
|
||||
class UpdateRepository @Inject constructor(retrofit: Retrofit) {
|
||||
private val api: ClientVersionApi = retrofit.create(ClientVersionApi::class.java)
|
||||
class UpdateRepository(
|
||||
private val api: ClientVersionApi,
|
||||
private val apk: ApkSource,
|
||||
private val dir: File,
|
||||
private val installedCode: Long,
|
||||
private val installedName: String,
|
||||
private val offers: Boolean,
|
||||
// A download left by older builds of the app, which nothing else removes.
|
||||
private val legacyApk: File? = null,
|
||||
) {
|
||||
private val turns = Mutex()
|
||||
|
||||
suspend fun getLatest(): UpdateInfo = api.getVersion().toDomain()
|
||||
suspend fun check(): Offer {
|
||||
if (!offers) return Offer.Current
|
||||
return try {
|
||||
val info = api.getVersion().toDomain()
|
||||
if (isUpdateAvailable(info.code, info.version, installedCode, installedName)) {
|
||||
Offer.Newer(info)
|
||||
} else {
|
||||
Offer.Current
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: HttpException) {
|
||||
// 404 is the server saying it bundles no app: nothing to offer.
|
||||
if (e.code() == HTTP_NOT_FOUND) Offer.Current else Offer.Problem(ErrorCopy.fromThrowable(e))
|
||||
} catch (e: IOException) {
|
||||
Offer.Problem(ErrorCopy.fromThrowable(e))
|
||||
} catch (e: SerializationException) {
|
||||
Offer.Problem(ErrorCopy.fromThrowable(e))
|
||||
}
|
||||
}
|
||||
|
||||
/** The package for [info], when it is already here and whole. */
|
||||
fun ready(info: UpdateInfo): File? =
|
||||
waiting(info).takeIf { it.isFile && it.length() == info.sizeBytes }
|
||||
|
||||
/**
|
||||
* The server's app, checked against the size and digest the server gave,
|
||||
* so what is handed to the installer is the build that was offered, all
|
||||
* of it. [progress] is called with 0 to 1 as it arrives.
|
||||
*/
|
||||
suspend fun download(info: UpdateInfo, progress: (Float) -> Unit): Download =
|
||||
turns.withLock { ready(info)?.let { Download.Ready(it) } ?: fetch(info, progress) }
|
||||
|
||||
/** Drops every package kept here: none is on offer any more. */
|
||||
suspend fun tidy() {
|
||||
turns.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
dir.listFiles()?.forEach { it.delete() }
|
||||
legacyApk?.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes to a part file and gives it the build's name only once it
|
||||
* checks out, so a download cut off partway is never taken for one that
|
||||
* finished. Any package for another build goes then.
|
||||
*/
|
||||
private suspend fun fetch(info: UpdateInfo, progress: (Float) -> Unit): Download {
|
||||
val target = waiting(info)
|
||||
val part = File(dir, target.name + PART)
|
||||
return try {
|
||||
val digest = withContext(Dispatchers.IO) {
|
||||
save(apk.open(info.apkUrl), part, info.sizeBytes, progress)
|
||||
}
|
||||
withContext(Dispatchers.IO) { settle(info, part, target, digest) }
|
||||
} catch (e: CancellationException) {
|
||||
part.delete()
|
||||
throw e
|
||||
} catch (e: IOException) {
|
||||
part.delete()
|
||||
Download.Problem("Couldn't download the update: ${ErrorCopy.fromThrowable(e)}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun settle(info: UpdateInfo, part: File, target: File, digest: String): Download {
|
||||
// A server that predates the digest leaves the size as the only check.
|
||||
val whole = part.length() == info.sizeBytes &&
|
||||
(info.sha256.isEmpty() || digest.equals(info.sha256, ignoreCase = true))
|
||||
return if (whole && part.renameTo(target)) {
|
||||
dir.listFiles()?.filter { it != target }?.forEach { it.delete() }
|
||||
Download.Ready(target)
|
||||
} else {
|
||||
part.delete()
|
||||
Download.Problem("The download did not match the app the server offered. Try again.")
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the package for [info] waits: named for its build. */
|
||||
private fun waiting(info: UpdateInfo): File =
|
||||
File(dir, "minstrel-${info.code ?: info.version.replace(UNSAFE, "_")}.apk")
|
||||
|
||||
private companion object {
|
||||
const val PART = ".part"
|
||||
val UNSAFE = Regex("[^A-Za-z0-9._-]")
|
||||
}
|
||||
}
|
||||
|
||||
private fun UpdateInfoWire.toDomain(): UpdateInfo = UpdateInfo(
|
||||
@@ -24,20 +161,55 @@ private fun UpdateInfoWire.toDomain(): UpdateInfo = UpdateInfo(
|
||||
channel = channel,
|
||||
apkUrl = apkUrl,
|
||||
sizeBytes = sizeBytes,
|
||||
sha256 = sha256,
|
||||
)
|
||||
|
||||
/** Writes [body] to [file], reporting progress against [size], and returns the SHA-256 written, in hex. */
|
||||
internal fun save(body: ResponseBody, file: File, size: Long, progress: (Float) -> Unit): String {
|
||||
file.parentFile?.mkdirs()
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
// Closing the stream closes the body.
|
||||
body.byteStream().use { input ->
|
||||
file.outputStream().use { output -> copy(input, output, digest) { written -> report(written, size, progress) } }
|
||||
}
|
||||
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun copy(input: InputStream, output: OutputStream, digest: MessageDigest, written: (Long) -> Unit) {
|
||||
val buffer = ByteArray(BUFFER_BYTES)
|
||||
var total = 0L
|
||||
var read = input.read(buffer)
|
||||
while (read >= 0) {
|
||||
output.write(buffer, 0, read)
|
||||
digest.update(buffer, 0, read)
|
||||
total += read
|
||||
written(total)
|
||||
read = input.read(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
private fun report(written: Long, size: Long, progress: (Float) -> Unit) {
|
||||
if (size > 0) progress((written.toFloat() / size).coerceAtMost(1f))
|
||||
}
|
||||
|
||||
private const val BUFFER_BYTES = 64 * 1024
|
||||
|
||||
/**
|
||||
* True when [server] should be offered over the installed build.
|
||||
* True when [serverName] / [serverCode] should be offered over the installed
|
||||
* build. Two questions, each asked of the value that answers it.
|
||||
*
|
||||
* **Decide on the ordering key whenever the server sends one.** That is the
|
||||
* same value Android's package installer compares, so an offer made this way
|
||||
* implies an install the platform will actually accept. Comparing NAMES
|
||||
* while the platform installs by `versionCode` keeps nothing consistent
|
||||
* between the two orderings: it can offer a build Android then refuses as
|
||||
* a downgrade, or stay quiet about one it would take.
|
||||
* **May it be installed over this one** is the ordering key's, whenever the
|
||||
* server sends one: it is the same value Android's package installer
|
||||
* compares, so an offer made on it is an install the platform will take.
|
||||
*
|
||||
* Name comparison survives only as the fallback for a server that predates
|
||||
* the field. A null code means "this server cannot tell me" — never "zero" —
|
||||
* **Is it another app at all** is the name's, compared for equality only.
|
||||
* Every server image builds the app again at a later minute, so the code
|
||||
* rises with a CI re-run or a change outside the shipped set while the app
|
||||
* itself does not; asking only the first question offered the installed app
|
||||
* to itself after each one.
|
||||
*
|
||||
* Name ordering survives only as the fallback for a server that predates the
|
||||
* code. A null code means "this server cannot tell me" — never "zero" —
|
||||
* because treating absent as zero would rank every such server as infinitely
|
||||
* old and offer its build to everyone, forever.
|
||||
*/
|
||||
@@ -48,7 +220,7 @@ fun isUpdateAvailable(
|
||||
installedName: String,
|
||||
): Boolean =
|
||||
if (serverCode != null) {
|
||||
serverCode > installedCode
|
||||
serverCode > installedCode && serverName != installedName
|
||||
} else {
|
||||
isVersionNewer(serverName, installedName)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The build Home last told the listener of, and when: on disk, so an app
|
||||
* Android reopened does not say the same news again.
|
||||
*/
|
||||
@Singleton
|
||||
class UpdateTold @Inject constructor(@ApplicationContext context: Context) {
|
||||
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun build(): String = prefs.getString(BUILD, "").orEmpty()
|
||||
|
||||
fun at(): Long = prefs.getLong(AT, 0L)
|
||||
|
||||
fun told(build: String, at: Long) {
|
||||
prefs.edit().putString(BUILD, build).putLong(AT, at).apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PREFS_NAME = "update_told"
|
||||
const val BUILD = "build"
|
||||
const val AT = "at"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The key a build is told under: its code, or its name from a server that
|
||||
* sends no code.
|
||||
*/
|
||||
internal fun buildKey(code: Long?, version: String): String = code?.toString() ?: version
|
||||
|
||||
/**
|
||||
* Whether a build on offer is said again: a build not yet told of is news at
|
||||
* once; the same one again a day later, until it is installed. A clock that
|
||||
* went backwards says it rather than hiding an update for a day.
|
||||
*/
|
||||
internal fun dueToSay(build: String, toldBuild: String, toldAt: Long, now: Long): Boolean {
|
||||
val since = now - toldAt
|
||||
return build != toldBuild || since < 0 || since >= SAY_AGAIN_AFTER_MS
|
||||
}
|
||||
|
||||
/** What Home's snackbar says of an offer; its button opens the sheet. */
|
||||
internal fun toldWords(version: String, ready: Boolean): String =
|
||||
if (ready) {
|
||||
"Version $version of the app is downloaded and ready."
|
||||
} else {
|
||||
"Version $version of the app is on your server."
|
||||
}
|
||||
|
||||
/** The snackbar's button: Install when there is nothing left to download. */
|
||||
internal fun toldAction(ready: Boolean): String = if (ready) "Install" else "Update"
|
||||
|
||||
internal const val SAY_AGAIN_AFTER_MS = 24L * 60 * 60 * 1000
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.fabledsword.minstrel.update.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
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.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.models.UpdateInfo
|
||||
import com.fabledsword.minstrel.settings.ui.formatBytes
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.UpdateUiState
|
||||
|
||||
/**
|
||||
* The app's own version, the newer one when the server has it, and a check
|
||||
* that is always there. Settings shows it as its About card, and the sheet
|
||||
* Home's update news opens holds the very same card, so there is one place
|
||||
* the app installs from. Back from allowing installs, the update the
|
||||
* listener asked for carries on.
|
||||
*/
|
||||
@Composable
|
||||
fun AppVersionCard(model: UpdateViewModel, modifier: Modifier = Modifier) {
|
||||
LifecycleResumeEffect(model) {
|
||||
model.resumed()
|
||||
onPauseOrDispose {}
|
||||
}
|
||||
val state by model.state.collectAsStateWithLifecycle()
|
||||
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "About",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = "Installed: Minstrel ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
val offer = state.offer
|
||||
if (offer != null) {
|
||||
Offered(offer, state, model::update)
|
||||
} else {
|
||||
Text(
|
||||
text = versionWords(state, selfUpdates = !BuildConfig.DEBUG),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
state.problem?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
TextButton(
|
||||
onClick = model::check,
|
||||
enabled = !BuildConfig.DEBUG && !state.checking && state.stage == InstallStage.IDLE,
|
||||
) { Text(if (state.checking) "Checking…" else "Check for updates") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The newer build, and the one button that fetches and installs it. */
|
||||
@Composable
|
||||
private fun Offered(offer: UpdateInfo, state: UpdateUiState, update: () -> Unit) {
|
||||
Text(offeredWords(offer, state.ready), style = MaterialTheme.typography.bodyMedium)
|
||||
when (state.stage) {
|
||||
InstallStage.IDLE -> Button(onClick = update, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(if (state.ready) "Install" else "Update")
|
||||
}
|
||||
InstallStage.DOWNLOADING ->
|
||||
LinearProgressIndicator(progress = { state.progress }, modifier = Modifier.fillMaxWidth())
|
||||
InstallStage.INSTALLING -> Text("Installing…", style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether this app is the newest, as far as the server has said. */
|
||||
internal fun versionWords(state: UpdateUiState, selfUpdates: Boolean = true): String = when {
|
||||
// Its own package and the debug key: the server's app would install beside it, never over it.
|
||||
!selfUpdates -> "Debug build: updates install from Android Studio."
|
||||
state.checking -> "Checking for a newer version…"
|
||||
state.unchecked != null -> "Couldn't check for a newer version: ${state.unchecked}"
|
||||
else -> "This is the newest version."
|
||||
}
|
||||
|
||||
/** The newer build, and whether it is already downloaded. */
|
||||
internal fun offeredWords(offer: UpdateInfo, ready: Boolean): String =
|
||||
if (ready) {
|
||||
"A new version of the app is downloaded and ready: ${describe(offer)}"
|
||||
} else {
|
||||
"A new version of the app is on your server: ${describe(offer)}"
|
||||
}
|
||||
|
||||
/** The name, the channel when it is the test line, and the size. */
|
||||
internal fun describe(offer: UpdateInfo): String {
|
||||
val channel = if (offer.channel == "dev") " (test build)" else ""
|
||||
return "${offer.version}$channel · ${formatBytes(offer.sizeBytes)}"
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
package com.fabledsword.minstrel.update.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.composables.icons.lucide.Download
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.X
|
||||
import com.fabledsword.minstrel.models.UpdateInfo
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.isBusy
|
||||
|
||||
/**
|
||||
* Shell-level soft banner that nudges an available update. Renders
|
||||
* nothing until [UpdateBannerController] surfaces a newer bundled APK
|
||||
* (and the user hasn't dismissed that version): a download glyph,
|
||||
* "Update Minstrel · {version} available", an Install button
|
||||
* (download → system installer), and a dismiss X. Uses an understated
|
||||
* surface tone, not the alarming error color reserved for
|
||||
* `VersionTooOldBanner`.
|
||||
*
|
||||
* [ApkInstaller][com.fabledsword.minstrel.update.data.ApkInstaller]
|
||||
* exposes no byte progress, so the downloading state is an
|
||||
* indeterminate bar.
|
||||
*/
|
||||
@Composable
|
||||
fun UpdateBanner(viewModel: UpdateBannerViewModel = hiltViewModel()) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val info = state.info
|
||||
AnimatedVisibility(
|
||||
visible = info != null,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
) {
|
||||
// info can flip to null during the exit animation; hold the last
|
||||
// non-null so the collapsing banner keeps its content.
|
||||
val shown = info ?: return@AnimatedVisibility
|
||||
BannerBody(
|
||||
info = shown,
|
||||
stage = state.stage,
|
||||
message = state.message,
|
||||
onInstall = { viewModel.install(shown) },
|
||||
onDismiss = { viewModel.dismiss(shown.version) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BannerBody(
|
||||
info: UpdateInfo,
|
||||
stage: InstallStage,
|
||||
message: String?,
|
||||
onInstall: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(start = 16.dp, top = 8.dp, end = 4.dp, bottom = 8.dp),
|
||||
) {
|
||||
BannerRow(info = info, stage = stage, onInstall = onInstall, onDismiss = onDismiss)
|
||||
if (stage.isBusy()) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
if (message != null) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BannerRow(
|
||||
info: UpdateInfo,
|
||||
stage: InstallStage,
|
||||
onInstall: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.Download,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = if (stage == InstallStage.ERROR) {
|
||||
"Update failed"
|
||||
} else {
|
||||
"Update Minstrel · ${info.version} available"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onInstall, enabled = !stage.isBusy()) {
|
||||
// Downloading and installing are separate words because they're now
|
||||
// separate waits — the install half suspends on the platform, which
|
||||
// may be sitting on a confirm dialog.
|
||||
Text(
|
||||
when (stage) {
|
||||
InstallStage.DOWNLOADING -> "Downloading…"
|
||||
InstallStage.INSTALLING -> "Installing…"
|
||||
else -> "Install"
|
||||
},
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
imageVector = Lucide.X,
|
||||
contentDescription = "Dismiss",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package com.fabledsword.minstrel.update.ui
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.models.UpdateInfo
|
||||
import com.fabledsword.minstrel.update.data.ApkInstaller
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.UpdateBannerController
|
||||
import com.fabledsword.minstrel.update.data.isBusy
|
||||
import com.fabledsword.minstrel.update.data.message
|
||||
import com.fabledsword.minstrel.update.data.stage
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
|
||||
data class UpdateBannerUiState(
|
||||
val info: UpdateInfo? = null,
|
||||
val stage: InstallStage = InstallStage.IDLE,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Thin VM over [UpdateBannerController]. Surfaces the available update
|
||||
* and runs the download → install handoff via [ApkInstaller], mirroring
|
||||
* the About card's flow (route to "install unknown apps" settings first
|
||||
* when the permission is missing).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class UpdateBannerViewModel @Inject constructor(
|
||||
private val controller: UpdateBannerController,
|
||||
private val installer: ApkInstaller,
|
||||
) : ViewModel() {
|
||||
|
||||
private val installState = MutableStateFlow(InstallSnapshot(InstallStage.IDLE, null))
|
||||
|
||||
val uiState: StateFlow<UpdateBannerUiState> =
|
||||
combine(controller.available, installState) { info, install ->
|
||||
UpdateBannerUiState(info = info, stage = install.stage, message = install.message)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
initialValue = UpdateBannerUiState(),
|
||||
)
|
||||
|
||||
fun dismiss(version: String) = controller.dismiss(version)
|
||||
|
||||
fun install(info: UpdateInfo) {
|
||||
if (installState.value.stage.isBusy()) return
|
||||
if (!installer.canInstall()) {
|
||||
installer.requestInstallPermission()
|
||||
installState.value = InstallSnapshot(
|
||||
InstallStage.IDLE,
|
||||
"Allow installs for Minstrel, then tap Install again.",
|
||||
)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
installState.value = InstallSnapshot(InstallStage.DOWNLOADING, null)
|
||||
val apk = download(info.apkUrl)
|
||||
if (apk != null) {
|
||||
// Await the platform's verdict rather than firing an intent and
|
||||
// assuming it worked. On a silent install this suspends until
|
||||
// the process is replaced, so the line below is only reached
|
||||
// when the install did NOT simply succeed.
|
||||
installState.value = InstallSnapshot(InstallStage.INSTALLING, null)
|
||||
val outcome = installer.install(apk)
|
||||
installState.value = InstallSnapshot(outcome.stage(), outcome.message())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun download(apkUrl: String): File? =
|
||||
runCatching { installer.downloadApk(apkUrl) }
|
||||
.onFailure { e ->
|
||||
installState.value = InstallSnapshot(
|
||||
InstallStage.ERROR,
|
||||
"Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
|
||||
)
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
private data class InstallSnapshot(val stage: InstallStage, val message: String?)
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.fabledsword.minstrel.update.ui
|
||||
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.fabledsword.minstrel.update.data.toldAction
|
||||
import com.fabledsword.minstrel.update.data.toldWords
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/** How often a screen on show asks the server about a newer app by itself. */
|
||||
private const val CHECK_EVERY_MS = 5 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* A newer app, told on Home (after Librarian's and Gallery's update tray): a
|
||||
* snackbar at the foot of the screen, never a dialog, and its button opens a
|
||||
* sheet holding the very card Settings shows, so there is one place the app
|
||||
* installs from. Each build is told at most once a day. The sheet stays open
|
||||
* through a trip to allow installs, which is where the card carries on from.
|
||||
*/
|
||||
@Composable
|
||||
fun BoxScope.UpdateNotices(model: UpdateViewModel) {
|
||||
val notices = remember { SnackbarHostState() }
|
||||
var updating by rememberSaveable { mutableStateOf(false) }
|
||||
CheckWhileShown(model)
|
||||
LaunchedEffect(model) {
|
||||
model.tellHere()
|
||||
model.news.collect { news ->
|
||||
val tapped = notices.showSnackbar(
|
||||
message = toldWords(news.offer.version, news.ready),
|
||||
actionLabel = toldAction(news.ready),
|
||||
withDismissAction = true,
|
||||
duration = SnackbarDuration.Long,
|
||||
)
|
||||
if (tapped == SnackbarResult.ActionPerformed) updating = true
|
||||
}
|
||||
}
|
||||
SnackbarHost(notices, Modifier.align(Alignment.BottomCenter).padding(12.dp))
|
||||
if (updating) UpdateSheet(model) { updating = false }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun UpdateSheet(model: UpdateViewModel, dismiss: () -> Unit) {
|
||||
ModalBottomSheet(onDismissRequest = dismiss) {
|
||||
AppVersionCard(
|
||||
model,
|
||||
Modifier.navigationBarsPadding().padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the server about a newer app when the screen opens, as soon as the
|
||||
* app comes back to the front, and every [CHECK_EVERY_MS] while it is on
|
||||
* screen. A screen in the background never asks.
|
||||
*/
|
||||
@Composable
|
||||
fun CheckWhileShown(model: UpdateViewModel) {
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
LaunchedEffect(model, lifecycle) {
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
while (true) {
|
||||
model.check()
|
||||
delay(CHECK_EVERY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.fabledsword.minstrel.update.ui
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.update.data.AfterAsking
|
||||
import com.fabledsword.minstrel.update.data.AppUpdates
|
||||
import com.fabledsword.minstrel.update.data.InstallStage
|
||||
import com.fabledsword.minstrel.update.data.UpdateNews
|
||||
import com.fabledsword.minstrel.update.data.UpdateUiState
|
||||
import com.fabledsword.minstrel.update.data.afterAsking
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* A screen's handle on [AppUpdates]. What it adds is the one thing that
|
||||
* belongs to the screen: that the listener pressed Update and was sent to
|
||||
* allow installs. That is kept in [saved], so it outlives the process
|
||||
* Android may restart from the settings page, and the update carries on
|
||||
* when they come back ([resumed]) instead of asking them to tap again.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class UpdateViewModel @Inject constructor(
|
||||
private val updates: AppUpdates,
|
||||
private val saved: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
val state: StateFlow<UpdateUiState> = updates.state
|
||||
val news: Flow<UpdateNews> = updates.news
|
||||
|
||||
/** Asks the server again; once the answer is in, an update asked for before a restart carries on. */
|
||||
fun check() {
|
||||
viewModelScope.launch {
|
||||
updates.check().join()
|
||||
carryOn()
|
||||
}
|
||||
}
|
||||
|
||||
fun tellHere() = updates.tellHere()
|
||||
|
||||
/** The app is in front again, perhaps back from allowing installs. */
|
||||
fun resumed() {
|
||||
updates.lookForReady()
|
||||
carryOn()
|
||||
}
|
||||
|
||||
fun update() {
|
||||
val now = state.value
|
||||
when {
|
||||
now.offer == null || now.stage != InstallStage.IDLE -> Unit
|
||||
!updates.mayInstall() -> {
|
||||
saved[ASKED] = true
|
||||
updates.askToInstall()
|
||||
}
|
||||
else -> updates.fetchAndInstall()
|
||||
}
|
||||
}
|
||||
|
||||
private fun carryOn() {
|
||||
val now = state.value
|
||||
val next = afterAsking(
|
||||
asked = saved.get<Boolean>(ASKED) == true,
|
||||
mayInstall = updates.mayInstall(),
|
||||
offered = now.offer != null,
|
||||
busy = now.stage != InstallStage.IDLE,
|
||||
)
|
||||
when (next) {
|
||||
AfterAsking.WAIT -> Unit
|
||||
AfterAsking.REFUSED -> {
|
||||
saved[ASKED] = false
|
||||
updates.refused()
|
||||
}
|
||||
AfterAsking.FETCH -> {
|
||||
saved[ASKED] = false
|
||||
updates.fetchAndInstall()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** The listener asked for the update and was sent to allow installs. */
|
||||
const val ASKED = "update.asked_to_allow"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/** Back from the "install unknown apps" page, an update that was asked for carries on. */
|
||||
class AfterAskingTest {
|
||||
@Test
|
||||
fun `allowed now, the update carries on`() {
|
||||
assertEquals(AfterAsking.FETCH, afterAsking(asked = true, mayInstall = true, offered = true, busy = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `back without allowing it, the listener is told and the offer stays`() {
|
||||
assertEquals(AfterAsking.REFUSED, afterAsking(asked = true, mayInstall = false, offered = true, busy = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `nothing asked, nothing happens`() {
|
||||
assertEquals(AfterAsking.WAIT, afterAsking(asked = false, mayInstall = true, offered = true, busy = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an offer not known yet, or an install under way, waits`() {
|
||||
assertEquals(AfterAsking.WAIT, afterAsking(asked = true, mayInstall = true, offered = false, busy = false))
|
||||
assertEquals(AfterAsking.WAIT, afterAsking(asked = true, mayInstall = true, offered = true, busy = true))
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
import com.fabledsword.minstrel.models.wire.UpdateInfoWire
|
||||
import com.fabledsword.minstrel.update.api.ClientVersionApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.security.MessageDigest
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The fetch worker and the listener's Update both download through here; a
|
||||
* package only counts as ready once it is the whole build that was offered.
|
||||
*/
|
||||
class UpdateRepositoryTest {
|
||||
@TempDir
|
||||
lateinit var dir: File
|
||||
|
||||
private val apkBytes = "the newer app".toByteArray()
|
||||
private val sha = MessageDigest.getInstance("SHA-256").digest(apkBytes).joinToString("") { "%02x".format(it) }
|
||||
|
||||
private fun wire(code: Long? = 200L, version: String = "2026.10.26.1200", sha256: String = sha) = UpdateInfoWire(
|
||||
version = version,
|
||||
code = code,
|
||||
sizeBytes = apkBytes.size.toLong(),
|
||||
sha256 = sha256,
|
||||
)
|
||||
|
||||
private var downloads = 0
|
||||
|
||||
private fun repo(
|
||||
version: () -> UpdateInfoWire = { wire() },
|
||||
body: () -> ByteArray = { apkBytes },
|
||||
offers: Boolean = true,
|
||||
) = UpdateRepository(
|
||||
api = object : ClientVersionApi {
|
||||
override suspend fun getVersion(): UpdateInfoWire = version()
|
||||
},
|
||||
apk = ApkSource {
|
||||
downloads += 1
|
||||
body().toResponseBody()
|
||||
},
|
||||
dir = dir,
|
||||
installedCode = 100L,
|
||||
installedName = "2026.10.20.0900",
|
||||
offers = offers,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a newer build is offered`() = runTest {
|
||||
assertIs<Offer.Newer>(repo().check())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same app built again at a later minute is not offered`() = runTest {
|
||||
assertEquals(Offer.Current, repo(version = { wire(version = "2026.10.20.0900") }).check())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server with no app to offer is not a problem`() = runTest {
|
||||
val none = repo(version = { throw HttpException(Response.error<Any>(404, "".toResponseBody())) })
|
||||
assertEquals(Offer.Current, none.check())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server out of reach is a problem, not an all-clear`() = runTest {
|
||||
assertIs<Offer.Problem>(repo(version = { throw IOException("unreachable") }).check())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a debug build is never offered one`() = runTest {
|
||||
assertEquals(Offer.Current, repo(offers = false).check())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a build already fetched is installed from disk without a second download`() = runTest {
|
||||
val r = repo()
|
||||
val info = (r.check() as Offer.Newer).info
|
||||
assertIs<Download.Ready>(r.download(info) {})
|
||||
assertNotNull(r.ready(info))
|
||||
val again = r.download(info) {}
|
||||
assertIs<Download.Ready>(again)
|
||||
assertEquals(1, downloads)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a download cut off partway is not taken for the build`() = runTest {
|
||||
val r = repo(body = { apkBytes.copyOf(5) })
|
||||
val info = (r.check() as Offer.Newer).info
|
||||
assertIs<Download.Problem>(r.download(info) {})
|
||||
assertNull(r.ready(info))
|
||||
assertTrue(dir.listFiles().orEmpty().isEmpty(), "the part file is removed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a download that does not match the digest is not taken for the build`() = runTest {
|
||||
val r = repo(body = { "the other app".toByteArray() }) // same length, other bytes
|
||||
val info = (r.check() as Offer.Newer).info
|
||||
assertIs<Download.Problem>(r.download(info) {})
|
||||
assertNull(r.ready(info))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tidy drops a package kept for a build no longer offered`() = runTest {
|
||||
val r = repo()
|
||||
val info = (r.check() as Offer.Newer).info
|
||||
r.download(info) {}
|
||||
r.tidy()
|
||||
assertNull(r.ready(info))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** Home tells of each build at most once a day, and says whether it is ready. */
|
||||
class UpdateToldTest {
|
||||
private val day = SAY_AGAIN_AFTER_MS
|
||||
private val t0 = 1_000_000_000L
|
||||
|
||||
@Test
|
||||
fun `a build not yet told of is told at once`() {
|
||||
assertTrue(dueToSay("3523900", toldBuild = "3523847", toldAt = t0, now = t0 + 1))
|
||||
assertTrue(dueToSay("3523900", toldBuild = "", toldAt = 0L, now = t0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same build is not told again within a day`() {
|
||||
assertFalse(dueToSay("3523900", toldBuild = "3523900", toldAt = t0, now = t0 + day - 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same build is told again the day after`() {
|
||||
assertTrue(dueToSay("3523900", toldBuild = "3523900", toldAt = t0, now = t0 + day))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a clock set back tells rather than hiding the update`() {
|
||||
assertTrue(dueToSay("3523900", toldBuild = "3523900", toldAt = t0, now = t0 - 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a build is keyed by its code, or by its name from a server with none`() {
|
||||
assertEquals("3523900", buildKey(3523900L, "2026.09.10.1432"))
|
||||
assertEquals("2026.09.10.1432", buildKey(null, "2026.09.10.1432"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the snackbar says whether the update is already here`() {
|
||||
assertEquals(
|
||||
"Version 2026.10.26.1200 of the app is on your server.",
|
||||
toldWords("2026.10.26.1200", ready = false),
|
||||
)
|
||||
assertEquals(
|
||||
"Version 2026.10.26.1200 of the app is downloaded and ready.",
|
||||
toldWords("2026.10.26.1200", ready = true),
|
||||
)
|
||||
assertEquals("Update", toldAction(ready = false))
|
||||
assertEquals("Install", toldAction(ready = true))
|
||||
}
|
||||
}
|
||||
+15
@@ -62,6 +62,21 @@ class UpdateVersioningTest {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every server image builds the app again at a later minute, so a CI
|
||||
* re-run, or a change outside the shipped set, raises the code while the
|
||||
* app is the same one. The name says so, and it is not offered.
|
||||
*/
|
||||
@Test
|
||||
fun `the same app built again at a later minute is not offered`() {
|
||||
assertFalse(
|
||||
isUpdateAvailable(
|
||||
serverCode = 3523900, serverName = "2026.09.10.1432",
|
||||
installedCode = 3523847, installedName = "2026.09.10.1432",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to the name when the server reports no ordering key`() {
|
||||
assertTrue(
|
||||
|
||||
+41
-11
@@ -3,11 +3,17 @@
|
||||
# Derives the three values a build is stamped with, and the tag that names it.
|
||||
#
|
||||
# name=YYYY.MM.DD.HHMM label for people, from the timestamp of the newest
|
||||
# commit that CHANGED SOMETHING SHIPPED (see SHIPPED)
|
||||
# commit that CHANGED SOMETHING SHIPPED (see SHIPPED,
|
||||
# or SHIPPED_APK with --apk)
|
||||
# code=<int> ordering key, minutes since 2020-01-01 at BUILD time
|
||||
# tag=v<name> what a release of this commit must be called
|
||||
#
|
||||
# Usage: ci/version.sh [<commit-ish>] (default HEAD)
|
||||
# Usage: ci/version.sh [--apk] [<commit-ish>] (default HEAD)
|
||||
#
|
||||
# --apk names the Android app from android/ alone. The app compares this
|
||||
# name with the one it is running, and a different name is what
|
||||
# offers an update — so a name that moved for a server-only commit
|
||||
# would offer every phone a rebuild of the app it already has.
|
||||
#
|
||||
# This exists as a script rather than inline workflow YAML for one reason:
|
||||
# release.yml only runs on `main` and on tags, so anything living inside it is
|
||||
@@ -30,6 +36,12 @@
|
||||
set -euo pipefail
|
||||
|
||||
readonly EPOCH_2020=1577836800 # 2020-01-01T00:00:00Z
|
||||
|
||||
apk=false
|
||||
if [ "${1:-}" = "--apk" ]; then
|
||||
apk=true
|
||||
shift
|
||||
fi
|
||||
readonly REF="${1:-HEAD}"
|
||||
|
||||
# Both clocks are overridable so a test can pin them. Nothing but tests should
|
||||
@@ -48,14 +60,17 @@ readonly REF="${1:-HEAD}"
|
||||
# wrongly included → version moves when nothing shipped. Cosmetic noise in
|
||||
# a string nobody sorts.
|
||||
#
|
||||
# THIS REPO SHIPS TWO ARTIFACTS FROM ONE DERIVATION, and that is why the list
|
||||
# is shorter than it looks like it should be. The server image ships cmd/,
|
||||
# internal/, shared/, web/, config.example.yaml and client/; the APK ships
|
||||
# android/. Neither ships the other's sources — but excluding android/ here
|
||||
# would stop an Android-only commit from moving the APK's OWN version, which
|
||||
# is the dangerous direction. So this is the union: exclude only what ships in
|
||||
# NEITHER, and accept that an Android commit also nudges the server's reported
|
||||
# version. Over-inclusion across the two, which is the harmless direction.
|
||||
# THIS LIST NAMES THE SERVER IMAGE, and it is shorter than it looks like it
|
||||
# should be. The image ships cmd/, internal/, shared/, web/,
|
||||
# config.example.yaml and client/ — and client/ holds the APK, so android/
|
||||
# stays in: an Android-only commit changes what the image carries. Exclude
|
||||
# only what ships in neither artifact; over-inclusion is the harmless
|
||||
# direction here.
|
||||
#
|
||||
# The APK itself is named from SHIPPED_APK (below), where over-inclusion is
|
||||
# NOT harmless: the app offers an update whenever the name differs from its
|
||||
# own, so a name moved by a server-only commit offers every phone a rebuild
|
||||
# of the app it already has.
|
||||
#
|
||||
# The family's other repos (roundtable / roundtable-android) each keep a
|
||||
# tighter list because they are separate repos with one artifact apiece. Do
|
||||
@@ -100,9 +115,24 @@ readonly SHIPPED=(
|
||||
':!web/vitest.setup.ts'
|
||||
)
|
||||
|
||||
# What the APK is built from: android/ and nothing else (the Gradle build
|
||||
# reads no path outside it). Still a denylist within it, for the same reason
|
||||
# as above — a new directory under android/ counts until named here.
|
||||
readonly SHIPPED_APK=(
|
||||
android
|
||||
':!android/app/src/test' # JVM unit tests
|
||||
':!android/*.md'
|
||||
)
|
||||
|
||||
if [ "${apk}" = true ]; then
|
||||
shipped=("${SHIPPED_APK[@]}")
|
||||
else
|
||||
shipped=("${SHIPPED[@]}")
|
||||
fi
|
||||
|
||||
commit_epoch="${MINSTREL_COMMIT_EPOCH:-}"
|
||||
if [ -z "${commit_epoch}" ]; then
|
||||
commit_epoch="$(git log --format=%ct -1 "${REF}" -- "${SHIPPED[@]}")"
|
||||
commit_epoch="$(git log --format=%ct -1 "${REF}" -- "${shipped[@]}")"
|
||||
# Loudly, on purpose. A silent fallback here is the landmine this whole
|
||||
# script exists to avoid: a plausible-looking version that is quietly wrong,
|
||||
# on a green run. Realistically this means a shallow clone (no commit in
|
||||
|
||||
@@ -17,8 +17,11 @@ package api
|
||||
// client treats 404 as "no update channel available."
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -62,6 +65,41 @@ var (
|
||||
clientAPKLastDownloadMu sync.Mutex
|
||||
)
|
||||
|
||||
// clientAPKSum is the bundled APK's sha256, kept until the file changes
|
||||
// under it (another size or modification time), so a version check every
|
||||
// few minutes from every phone does not re-read tens of megabytes each time.
|
||||
type clientAPKSum struct {
|
||||
size int64
|
||||
mod time.Time
|
||||
hex string
|
||||
}
|
||||
|
||||
var (
|
||||
clientAPKSumCache clientAPKSum
|
||||
clientAPKSumCacheMu sync.Mutex
|
||||
)
|
||||
|
||||
// clientAPKDigest returns the sha256 of the APK at path, in hex.
|
||||
func clientAPKDigest(path string, st os.FileInfo) (string, error) {
|
||||
clientAPKSumCacheMu.Lock()
|
||||
defer clientAPKSumCacheMu.Unlock()
|
||||
c := clientAPKSumCache
|
||||
if c.hex != "" && c.size == st.Size() && c.mod.Equal(st.ModTime()) {
|
||||
return c.hex, nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
clientAPKSumCache = clientAPKSum{size: st.Size(), mod: st.ModTime(), hex: hex.EncodeToString(h.Sum(nil))}
|
||||
return clientAPKSumCache.hex, nil
|
||||
}
|
||||
|
||||
func testResetClientAPKRateLimit() {
|
||||
clientAPKLastDownloadMu.Lock()
|
||||
defer clientAPKLastDownloadMu.Unlock()
|
||||
@@ -115,6 +153,11 @@ type clientVersionResponse struct {
|
||||
Channel string `json:"channel,omitempty"`
|
||||
APKURL string `json:"apk_url"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
// SHA256 is worked out from the file being served, not copied from the
|
||||
// sidecar, so it cannot disagree with the bytes the phone receives. The
|
||||
// app downloads ahead on Wi-Fi and installs later; this is how it knows
|
||||
// the file it kept is the whole build that was offered.
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
// handleClientVersion returns the bundled Android client version + a
|
||||
@@ -159,12 +202,19 @@ func (h *handlers) handleClientVersion(w http.ResponseWriter, _ *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
sum, err := clientAPKDigest(apkPath, stat)
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "client_version: digest apk", err)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, clientVersionResponse{
|
||||
Version: strings.TrimSpace(sidecar.Name),
|
||||
Code: sidecar.Code,
|
||||
Channel: strings.TrimSpace(sidecar.Channel),
|
||||
APKURL: "/api/client/apk",
|
||||
SizeBytes: stat.Size(),
|
||||
SHA256: sum,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -128,6 +130,39 @@ func TestClientVersion_200WithBothFiles(t *testing.T) {
|
||||
if resp.SizeBytes != size {
|
||||
t.Errorf("size_bytes: want %d, got %d", size, resp.SizeBytes)
|
||||
}
|
||||
want := sha256.Sum256([]byte("fake apk content"))
|
||||
if resp.SHA256 != hex.EncodeToString(want[:]) {
|
||||
t.Errorf("sha256: want the served file's digest %x, got %q", want, resp.SHA256)
|
||||
}
|
||||
}
|
||||
|
||||
// The digest is cached by size and modification time; a new APK dropped in
|
||||
// place must be answered with its own digest, not the old one's.
|
||||
func TestClientVersion_DigestFollowsAReplacedAPK(t *testing.T) {
|
||||
writeClientAssets(t, `{"name":"a","code":1}`)
|
||||
first := decodeClientVersion(t, getClientVersion(t))
|
||||
dir := os.Getenv("MINSTREL_CLIENT_APK_DIR")
|
||||
apk := filepath.Join(dir, clientAPKFilename)
|
||||
if err := os.WriteFile(apk, []byte("another, longer build"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := decodeClientVersion(t, getClientVersion(t))
|
||||
want := sha256.Sum256([]byte("another, longer build"))
|
||||
if second.SHA256 != hex.EncodeToString(want[:]) || second.SHA256 == first.SHA256 {
|
||||
t.Errorf("sha256 after replace: want %x, got %q (before: %q)", want, second.SHA256, first.SHA256)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeClientVersion(t *testing.T, rr *httptest.ResponseRecorder) clientVersionResponse {
|
||||
t.Helper()
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d (body: %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
var resp clientVersionResponse
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// A release published before ordering keys were recorded has a name and
|
||||
|
||||
@@ -272,73 +272,41 @@ func TestDevChannel_PublishesDevAloneAndNeverLatest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The two bundling paths must stay mutually exclusive. If the rebundle step's
|
||||
// condition were relaxed back to "not a tag", a dev push would run BOTH: stage
|
||||
// its freshly-built APK, then overwrite it with the previous release's. The
|
||||
// image would still build and the sidecar would still parse — it would just
|
||||
// quietly serve stale art to the channel whose whole job is being current.
|
||||
func TestDevChannel_RebundlePathIsMainOnly(t *testing.T) {
|
||||
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
yaml := string(body)
|
||||
// A merge to main is enough to put a new app in front of stable phones: main
|
||||
// builds the signed APK from its own commit and bakes it into :latest, as dev
|
||||
// does for :dev. The regression to guard is main falling back to a tag-only
|
||||
// APK build, which leaves every merged app change waiting on a release while
|
||||
// each build stays green.
|
||||
func TestEveryBranchShipsTheAppBuiltFromItsOwnCommit(t *testing.T) {
|
||||
jobs := releaseJobs(t)
|
||||
|
||||
i := strings.Index(yaml, "- name: Bundle latest release APK")
|
||||
if i < 0 {
|
||||
t.Fatal("no 'Bundle latest release APK' step")
|
||||
}
|
||||
step := yaml[i:]
|
||||
if j := strings.Index(step, "\n - name:"); j >= 0 {
|
||||
step = step[:j]
|
||||
}
|
||||
if !strings.Contains(step, "github.ref == 'refs/heads/main'") {
|
||||
t.Errorf("the rebundle step is not gated to main; a dev push would overwrite its own APK\n%s", step)
|
||||
}
|
||||
}
|
||||
|
||||
// The runner invokes `shell: bash` as `bash -e -o pipefail`. That makes any
|
||||
// command substitution whose grep matches NOTHING fatal at the assignment —
|
||||
// pipefail reports the grep's non-zero status even though `head` succeeded —
|
||||
// so the step dies before reaching the `if` written to handle the empty case.
|
||||
//
|
||||
// This is not hypothetical. It took down the first `main` build after the
|
||||
// version rework: the newest release predated sidecar assets, its
|
||||
// `.apk.version` grep matched nothing, and the step aborted instead of
|
||||
// falling through to the name-only branch that exists precisely for it. The
|
||||
// same latent hazard sat on the other two assignments and had simply never
|
||||
// fired, because their greps always matched.
|
||||
//
|
||||
// Pins the property that makes every "degrades gracefully" branch in that
|
||||
// step reachable at all.
|
||||
func TestBundleStep_GrepsCannotKillTheStep(t *testing.T) {
|
||||
body, err := os.ReadFile(filepath.Join(repoRoot(t), ".gitea", "workflows", "release.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
yaml := string(body)
|
||||
|
||||
i := strings.Index(yaml, "- name: Bundle latest release APK")
|
||||
if i < 0 {
|
||||
t.Fatal("no 'Bundle latest release APK' step")
|
||||
}
|
||||
step := yaml[i:]
|
||||
if j := strings.Index(step, "\n - name:"); j >= 0 {
|
||||
step = step[:j]
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(step, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "#") || !strings.Contains(trimmed, "grep") {
|
||||
continue
|
||||
build := strings.Join(strings.Fields(jobs["android-release"].If), " ")
|
||||
for _, ref := range []string{"refs/heads/main", "refs/heads/dev", "refs/tags/v"} {
|
||||
if !strings.Contains(build, ref) {
|
||||
t.Errorf("android-release does not build on %s, so that ref's image carries no app of its own\n if: %s", ref, build)
|
||||
}
|
||||
// Only assignments from a command substitution can abort the step.
|
||||
if !strings.Contains(trimmed, `="$(`) {
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(trimmed, "|| true") {
|
||||
t.Errorf("this assignment dies under pipefail when its grep matches nothing, "+
|
||||
"skipping the empty-case branch below it:\n %s", trimmed)
|
||||
}
|
||||
|
||||
image := strings.Join(strings.Fields(jobs["image-release"].If), " ")
|
||||
if !strings.Contains(image, "needs.android-release.result == 'success'") || strings.Contains(image, "'skipped'") {
|
||||
t.Errorf("image-release can publish without a freshly-built APK\n if: %s", image)
|
||||
}
|
||||
|
||||
yaml := releaseYAML(t)
|
||||
if strings.Contains(yaml, "releases/latest") {
|
||||
t.Error("release.yml fetches the latest release; an image bundling a release's APK ships an app older than its own commit")
|
||||
}
|
||||
|
||||
// Both bundling steps run on every ref. Sliced from image-release on,
|
||||
// because release-assets has a step of the same name.
|
||||
i := strings.Index(yaml, "\n image-release:")
|
||||
if i < 0 {
|
||||
t.Fatal("no image-release job")
|
||||
}
|
||||
for _, name := range []string{"Download signed APK artifact", "Stage bundled APK + version sidecar"} {
|
||||
step := stepBody(t, yaml[i:], name)
|
||||
if strings.Contains(step, "github.ref") {
|
||||
t.Errorf("%q is gated by ref, so some ref's image ships without its APK\n%s", name, step)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -376,7 +344,7 @@ func TestReleaseBuild_RefreshesTheChannelAndMintsNothingElse(t *testing.T) {
|
||||
arm, _, _ := imageTagArms(t, releaseYAML(t))
|
||||
|
||||
if !strings.Contains(arm, "${IMAGE}:latest") {
|
||||
t.Errorf("tag arm does not refresh :latest — the channel would keep serving the PREVIOUS release's APK until someone pushed to main\n%s", arm)
|
||||
t.Errorf("tag arm does not refresh :latest — the server would hand out a different APK build from the one attached to the Release\n%s", arm)
|
||||
}
|
||||
if strings.Contains(arm, "GITHUB_SHA") {
|
||||
t.Errorf("tag arm mints a :<sha> image; that would re-push main's immutable rollback target with different bundled contents\n%s", arm)
|
||||
@@ -455,7 +423,7 @@ func TestImageBuild_StampsADerivedVersionAndAChannel(t *testing.T) {
|
||||
t.Errorf("the main arm stamps the literal string \"main\" as a version; two images months apart would be indistinguishable\n%s", mainArm)
|
||||
}
|
||||
if !strings.Contains(yaml, "ci/version.sh HEAD | sed") {
|
||||
t.Error("the image job no longer derives its version from ci/version.sh — the version and the APK's version can now drift apart")
|
||||
t.Error("the image job no longer derives its version from ci/version.sh — the image's version has drifted out of test coverage")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,10 +476,11 @@ func commitFile(t *testing.T, dir, path, epoch string) {
|
||||
}
|
||||
|
||||
// versionIn runs ci/version.sh inside dir, reading real git rather than the
|
||||
// pinned-clock override, so the PATHSPEC is what is under test.
|
||||
func versionIn(t *testing.T, dir string) (string, error) {
|
||||
// pinned-clock override, so the PATHSPEC is what is under test. flags go
|
||||
// before the ref, as in `--apk`.
|
||||
func versionIn(t *testing.T, dir string, flags ...string) (string, error) {
|
||||
t.Helper()
|
||||
cmd := exec.Command(filepath.Join(repoRoot(t), "ci", "version.sh"), "HEAD")
|
||||
cmd := exec.Command(filepath.Join(repoRoot(t), "ci", "version.sh"), append(flags, "HEAD")...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null",
|
||||
@@ -558,11 +527,9 @@ func TestVersionName_IgnoresCommitsThatShipNothing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// android/ is deliberately NOT excluded, and that is the subtle half of the
|
||||
// list. It ships in no server image — but it is the APK's entire source, and
|
||||
// ONE script derives the version for both artifacts. Excluding it would stop
|
||||
// an Android-only commit from moving the APK's own version, which is exactly
|
||||
// the silent downgrade the versioning rework exists to prevent.
|
||||
// android/ is deliberately NOT excluded from the image's name: the image
|
||||
// bundles the APK under client/, so an Android-only commit changes what the
|
||||
// image carries, and an image that changed must not keep its old name.
|
||||
func TestVersionName_AndroidSourcesCount(t *testing.T) {
|
||||
dir := gitRepo(t)
|
||||
commitFile(t, dir, "internal/server/thing.go", "1757443736")
|
||||
@@ -573,7 +540,71 @@ func TestVersionName_AndroidSourcesCount(t *testing.T) {
|
||||
t.Fatalf("version.sh failed: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "name=2026.09.10.0042") {
|
||||
t.Errorf("an Android commit did not move the version; the APK would ship new code under its old version name\n%s", out)
|
||||
t.Errorf("an Android commit did not move the image's version; it carries a new APK under its old name\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The app offers an update whenever the server's APK has a different name
|
||||
// from its own, so the APK's name must move for Android commits and ONLY for
|
||||
// them. A name taken from the whole repo would offer every phone a rebuild of
|
||||
// the app it already has on each server-only deploy.
|
||||
func TestApkName_MovesOnlyForAndroidCommits(t *testing.T) {
|
||||
const (
|
||||
app = "1757443736" // 2025.09.09.1848
|
||||
server = "1789000920" // 2026.09.10.0042, later
|
||||
again = "1789600000" // 2026.09.16.2306, later still
|
||||
)
|
||||
dir := gitRepo(t)
|
||||
commitFile(t, dir, "android/app/src/main/Thing.kt", app)
|
||||
commitFile(t, dir, "internal/server/thing.go", server)
|
||||
commitFile(t, dir, "web/src/routes/page.svelte", server)
|
||||
|
||||
out, err := versionIn(t, dir, "--apk")
|
||||
if err != nil {
|
||||
t.Fatalf("version.sh --apk failed: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "name=2025.09.09.1848") {
|
||||
t.Errorf("a server-only commit moved the APK's name; every phone would be offered the app it already has\n%s", out)
|
||||
}
|
||||
// The image's name still follows the server commit, so the test above
|
||||
// is not passing because the flag is ignored and everything is stale.
|
||||
out, err = versionIn(t, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("version.sh failed: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "name=2026.09.10.0042") {
|
||||
t.Errorf("without --apk the name should follow the server commit\n%s", out)
|
||||
}
|
||||
|
||||
// A test-only Android commit ships nothing either.
|
||||
commitFile(t, dir, "android/app/src/test/java/A.kt", again)
|
||||
out, err = versionIn(t, dir, "--apk")
|
||||
if err != nil {
|
||||
t.Fatalf("version.sh --apk failed: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "name=2025.09.09.1848") {
|
||||
t.Errorf("an Android test-only commit moved the APK's name\n%s", out)
|
||||
}
|
||||
|
||||
// ...and a real Android commit must move it, or new app code would ship
|
||||
// under the name of the old and never be offered.
|
||||
commitFile(t, dir, "android/app/src/main/Thing.kt", again)
|
||||
out, err = versionIn(t, dir, "--apk")
|
||||
if err != nil {
|
||||
t.Fatalf("version.sh --apk failed: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "name=2026.09.16.2306") {
|
||||
t.Errorf("an Android commit did not move the APK's name; the new app would never be offered\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// The APK lane must ask for the APK's name. Without the flag it would still
|
||||
// build, sign and publish, and quietly go back to offering an update on every
|
||||
// server deploy.
|
||||
func TestReleaseWorkflow_NamesTheApkFromAndroidAlone(t *testing.T) {
|
||||
step := stepBody(t, releaseYAML(t), "Compute release version")
|
||||
if !strings.Contains(step, "ci/version.sh --apk HEAD") {
|
||||
t.Errorf("the APK lane does not derive its version with --apk\n%s", step)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user