A project can produce more than one APK

tdep-survey is one checkout with a backend and two independent Android
clients, so a project has to be able to declare two Apk components. It
could not: APK_PATTERNS was anchored at the project root, which reached
the first client and stopped, and package, strip, the variant list and
the download all came from AppEntry::apk_component()'s first match --
correct only while a project produced one APK.

Each component's builds are now found under its own cwd, which is what
cwd already meant everywhere else: the directory the build command runs
in, the subtree staleness is scoped to, a server's WorkingDirectory. A
component that declares none sits at the project root, so nothing about
the single-APK case changes. The alternative -- naming the file on the
component -- would have made a component a file path, and an app being a
project path rather than a file path is this project's central invariant.

Everything derived from an APK follows it onto the component: package,
previousPackage, strip, size, mtime, variants and the rename note, in a
nested `apk` block that a Server simply doesn't have. Two clients install
over different packages, so first-wins would have checked the installed
state of one and reported it as the other's -- which looks exactly like a
correct answer. For the same reason a download naming no component is
refused rather than guessed; naming none still answers for a project with
one, which is what lets the frozen /self/apk keep working.

On the phone the per-device state is keyed by project and component, so
the variant picker moved inside the component's own card, beside the
build it picks, and the installed state, size and icon are each their own
component's. A project building two clients shows no single icon of its
own rather than borrowing the first one's.

Measured against the real thing: pointed at tdep-survey, the two clients
resolve to their own builds (15 MB and 153 MB), strip applies only to the
one that asked for it (153 MB served as 52 MB), pressing Install on one
row installed that row's package and left the other row offering Install,
and a download with no component named answers 400 saying which flag to
pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-08-31 23:00:32 -04:00
1 parent db47972a25
commit c4e19c2e2b
9 files changed
+760 -309

No files matched your search

@@ -23,21 +23,30 @@ private const val DOWNLOAD_READ_TIMEOUT_MS = 15000
fun downloadApk(
context: Context,
entry: ManifestEntry,
component: String,
onProgress: (bytesRead: Long, total: Long) -> Unit,
): File {
// The chosen build travels with the request rather than being
// Which component's APK, always said rather than left to the server:
// a project can build two clients, and the server refuses to guess
// between them rather than serving the first, which would install the
// wrong app while looking like it worked.
//
// The chosen build travels with the request too rather than being
// stored on the server: it is this device's preference, and a
// second phone must not have its download changed by it. The
// server checks the path against the builds it can see, so a stale
// one falls back to the newest rather than naming a file.
val route =
when (val variant = chosenVariant(context, entry.key)) {
null -> entry.route
else -> "${entry.route}?variant=${URLEncoder.encode(variant, "UTF-8")}"
}
return downloadFromRoute(context, route, entry.key, onProgress)
// server checks the path against that component's builds, so a stale
// one falls back to its newest rather than naming a file.
val query = StringBuilder("?component=").append(encode(component))
chosenVariant(context, entry.key, component)?.let {
query.append("&variant=").append(encode(it))
}
// Named for the component as well, so two clients of one project do
// not overwrite each other's download on the way to the installer.
return downloadFromRoute(context, entry.route + query, "${entry.key}-$component", onProgress)
}
private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")
/**
* Fetches one APK from [route] into private storage, named [name], and returns the file.
*
@@ -9,26 +9,14 @@ data class ManifestEntry(
// is what lets the app list be edited at runtime from the Add screen
// with no rebuild here.
val label: String,
val filename: String,
// Where this project's APKs come from. Which of them is said with the
// request, so one route covers a project that builds two clients --
// see [ProjectComponent.apk].
val route: String,
// Null until this project has been built at least once -- there is no
// APK to read an identity out of before then, and inventing one would
// make the installed-version check compare against nothing.
val packageName: String?,
// What this project's APK used to install over, when it has been
// renamed. Android treats a renamed applicationId as an unrelated app,
// so that one is still installed and nothing will ever replace it --
// the card offers to remove it, but only while it is actually there.
val previousPackageName: String?,
// The project directory this app was added by. Shown on the card so
// it's possible to tell two similarly-named apps apart, and to spot an
// entry pointing somewhere unexpected.
val projectPath: String,
// Epoch seconds of the raw build's mtime, straight from the server --
// these are ad hoc local rebuilds with no CI bumping a version, so
// build freshness is the only meaningful signal, not a version code.
val mtime: Double,
val size: Long,
// True for an entry with an on-demand build step (see BuildStatus.kt)
// -- only then does this app call that entry's prepare/status routes,
// which 404 for an entry without one.
@@ -39,13 +27,10 @@ data class ManifestEntry(
// Force git's remote commands onto IPv4 for this project -- this
// machine's choice, editable from the card's settings.
val gitIpv4: Boolean,
// False when the project has no APK yet (never built, or cleaned).
// Such an entry is still listed rather than silently dropped -- it was
// added deliberately, so saying so beats it disappearing.
// Whether anything this project produces has been built. Per component
// is on the component; this is what the card's own "nothing here yet"
// line reads.
val built: Boolean,
// Every build discovered under the project, so a different one can be
// selected without another round trip.
val variants: List<ApkVariant>,
// What this project produces, in build order. One is the ordinary case
// and the card stays flat; more than one is drawn as a nested list, so
// a project that also runs a server says so without every single-app
@@ -144,6 +129,9 @@ data class ProjectComponent(
// The last is the ordinary case and not a fault.
val resourcesChecking: Boolean,
val resourcesError: String?,
// What there is to install, for a component that produces one. Null
// for a server, which builds nothing this phone installs.
val apk: ComponentApk?,
) {
// Only "behind" is worth saying. "Current" is what a card already
// implies, and "unknown" said out loud would be on most rows most of
@@ -167,6 +155,39 @@ data class ProjectComponent(
get() = state == "failed"
}
// The installable half of a component.
//
// Per component rather than per project, because a project can build two
// clients: they install over different packages, are different sizes, and
// have their own builds to choose between. Read off the project, the
// answer would be the first component's wearing the project's name -- and
// nothing on screen would say so.
data class ComponentApk(
// What the download is saved as on this device.
val filename: String,
// Null until this component has been built at least once -- there is
// no APK to read an identity out of before then, and inventing one
// would make the installed-version check compare against nothing.
val packageName: String?,
// What this component used to install over, when it has been renamed.
// Android treats a renamed applicationId as an unrelated app, so that
// one is still installed and nothing will ever replace it -- the card
// offers to remove it, but only while it is actually there.
val previousPackageName: String?,
// False when this component has no APK yet (never built, or cleaned).
// Its row is still drawn rather than silently dropped -- it was
// declared deliberately, so saying so beats it disappearing.
val built: Boolean,
// Epoch seconds of the raw build's mtime, straight from the server --
// these are ad hoc local rebuilds with no CI bumping a version, so
// build freshness is the only meaningful signal, not a version code.
val mtime: Double,
val size: Long,
// Every build discovered under this component, so a different one can
// be selected without another round trip.
val variants: List<ApkVariant>,
)
// One discovered build of an app. `variant` is the Gradle-style build
// variant name ("debug", "freeRelease") taken from the output directory.
data class ApkVariant(
@@ -191,14 +212,14 @@ data class Manifest(
// In the units PackageInfo.lastUpdateTime reports, which is what this is
// ever compared against (see InstalledBuilds.kt).
fun ManifestEntry.mtimeMillis(): Long = (mtime * 1000).toLong()
fun ComponentApk.mtimeMillis(): Long = (mtime * 1000).toLong()
// When this device has pinned a build, that build's timestamp is the one
// freshness is about -- the newest build being newer than the installed
// copy says nothing when the newest is not what would be installed. Falls
// back to the entry's own when the pinned one is gone, which is the same
// build the server would fall back to serving.
fun ManifestEntry.mtimeMillisFor(chosenVariantPath: String?): Long =
// back to this component's own when the pinned one is gone, which is the
// same build the server would fall back to serving.
fun ComponentApk.mtimeMillisFor(chosenVariantPath: String?): Long =
variants.firstOrNull { it.path == chosenVariantPath }?.let { (it.mtime * 1000).toLong() }
?: mtimeMillis()
@@ -290,17 +311,11 @@ fun fetchApp(key: String): ManifestEntry =
/** One app as the server describes it, shared by both reads above. */
private fun readEntry(entry: JSONObject): ManifestEntry {
val components = entry.optJSONArray("components")
val variants = entry.getJSONArray("variants")
return ManifestEntry(
key = entry.getString("key"),
label = entry.getString("label"),
filename = entry.getString("filename"),
route = entry.getString("route"),
packageName = entry.optString("package").ifEmpty { null },
previousPackageName = entry.optString("previousPackage").ifEmpty { null },
projectPath = entry.getString("projectPath"),
mtime = entry.getDouble("mtime"),
size = entry.getLong("size"),
needsBuild = entry.getBoolean("needsBuild"),
builtIn = entry.getBoolean("builtIn"),
gitIpv4 = entry.getBoolean("gitIpv4"),
@@ -339,8 +354,22 @@ private fun readEntry(entry: JSONObject): ManifestEntry {
configPresent = component.optBoolean("configPresent", false),
resourcesChecking = component.optBoolean("resourcesChecking", false),
resourcesError = component.optString("resourcesError").ifEmpty { null },
apk = component.optJSONObject("apk")?.let(::readApk),
)
},
)
}
/** The installable half of one component, absent for a server. */
private fun readApk(apk: JSONObject): ComponentApk {
val variants = apk.getJSONArray("variants")
return ComponentApk(
filename = apk.getString("filename"),
packageName = apk.optString("package").ifEmpty { null },
previousPackageName = apk.optString("previousPackage").ifEmpty { null },
built = apk.getBoolean("built"),
mtime = apk.getDouble("mtime"),
size = apk.getLong("size"),
variants =
(0 until variants.length()).map { j ->
val variant = variants.getJSONObject(j)
@@ -422,12 +422,17 @@ private fun AppListScreen(
// two effects that follow -- so a fresh manifest, a package-change
// broadcast, and a return from the system installer all go through the
// same one path rather than each refreshing these their own way.
var installedTimes by remember { mutableStateOf<Map<String, Long?>>(emptyMap()) }
var installedSizes by remember { mutableStateOf<Map<String, Long?>>(emptyMap()) }
// Which build each project is pinned to on *this* device, read from
//
// Two levels: a project, then a component of it. A project can build
// two clients, and they install over different packages -- one map
// keyed by project alone would answer for whichever was asked about
// last, on both rows.
var installedTimes by remember { mutableStateOf<Map<String, Map<String, Long?>>>(emptyMap()) }
var installedSizes by remember { mutableStateOf<Map<String, Map<String, Long?>>>(emptyMap()) }
// Which build each component is pinned to on *this* device, read from
// local storage rather than the manifest. Held as state so picking one
// redraws the card without a round trip.
var chosenVariants by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
var chosenVariants by remember { mutableStateOf<Map<String, Map<String, String>>>(emptyMap()) }
// The project whose pull came back saying its checkout and its remote
// share no history, waiting on an answer about throwing that history
// away. One slot rather than one per card: it is a modal, so only one
@@ -710,7 +715,7 @@ private fun AppListScreen(
}
}
fun startUpdate(entry: ManifestEntry) {
fun startUpdate(entry: ManifestEntry, component: String) {
scope.launch {
if (entry.needsBuild) {
cardStates = cardStates + (entry.key to CardState.Preparing(null))
@@ -741,7 +746,7 @@ private fun AppListScreen(
val file =
try {
withContext(Dispatchers.IO) {
downloadApk(context, entry) { read, total ->
downloadApk(context, entry, component) { read, total ->
val progress = if (total > 0) read.toFloat() / total else null
cardStates = cardStates + (entry.key to CardState.Downloading(progress))
}
@@ -814,19 +819,31 @@ private fun AppListScreen(
}
fun updateInstalledState(entries: List<ManifestEntry>) {
// A project with no build yet has no package, so there is nothing
// installed to ask about -- which is exactly what null already
// means to the callers of these two maps.
installedTimes = entries.associate { entry ->
entry.key to entry.packageName?.let { installedLastUpdateTimeMillis(context, it) }
// A component with no build yet has no package, so there is
// nothing installed to ask about -- which is exactly what null
// already means to the callers of these two maps.
fun <T> byComponent(read: (String) -> T?): Map<String, Map<String, T?>> =
entries.associate { entry ->
entry.key to
entry.components
.filter { !it.isServer }
.associate { component ->
component.name to component.apk?.packageName?.let(read)
}
}
installedTimes = byComponent { installedLastUpdateTimeMillis(context, it) }
installedSizes = byComponent { installedApkSizeBytes(context, it) }
chosenVariants = entries.associate { entry ->
entry.key to
entry.components
.filter { !it.isServer }
.mapNotNull { component ->
chosenVariant(context, entry.key, component.name)?.let {
component.name to it
}
}
.toMap()
}
installedSizes = entries.associate { entry ->
entry.key to entry.packageName?.let { installedApkSizeBytes(context, it) }
}
chosenVariants =
entries
.mapNotNull { entry -> chosenVariant(context, entry.key)?.let { entry.key to it } }
.toMap()
}
LaunchedEffect(Unit) { refresh() }
@@ -902,7 +919,11 @@ private fun AppListScreen(
val receiver =
registerPackageChangeReceiver(context) { packageName ->
currentEntries.value
?.takeIf { entries -> entries.any { it.packageName == packageName } }
?.takeIf { entries ->
entries.any { entry ->
entry.components.any { it.apk?.packageName == packageName }
}
}
?.let(::updateInstalledState)
}
onDispose { context.unregisterReceiver(receiver) }
@@ -1002,11 +1023,19 @@ private fun AppListScreen(
cardStates[entry.key].isBuilding() ||
!entry.built ||
entry.newCommits ||
!isUpToDate(
entry,
installedTimes[entry.key],
chosenVariants[entry.key],
)
// Any client of the project being
// behind is the project being
// behind: a card with one of two
// apps waiting has something
// waiting.
entry.components.any { component ->
val apk = component.apk ?: return@any false
!isUpToDate(
apk,
installedTimes[entry.key]?.get(component.name),
chosenVariants[entry.key]?.get(component.name),
)
}
}
// The two groups render identically; only the up-to-date one
@@ -1022,11 +1051,13 @@ private fun AppListScreen(
group.forEach { entry ->
AppCard(
entry = entry,
installedLastUpdateTimeMillis = installedTimes[entry.key],
chosenVariantPath = chosenVariants[entry.key],
installedSizeBytes = installedSizes[entry.key],
installedTimes = installedTimes[entry.key] ?: emptyMap(),
chosenVariants = chosenVariants[entry.key] ?: emptyMap(),
installedSizes = installedSizes[entry.key] ?: emptyMap(),
cardState = cardStates[entry.key],
onUpdate = { startUpdate(it) },
onUpdate = { updated, component ->
startUpdate(updated, component)
},
onPull = { startPull(entry) },
onRebuild = { startRebuild(entry) },
onRefresh = { refreshOne(entry) },
@@ -1037,7 +1068,7 @@ private fun AppListScreen(
manage(entry) { approveDeclaration(entry.key) }
},
onRemove = {
forgetVariant(context, entry.key)
forgetVariants(context, entry.key)
manage(entry, removes = true) { removeApp(entry.key) }
},
// Written here rather than sent to the server:
@@ -1045,14 +1076,23 @@ private fun AppListScreen(
// reload token is what redraws the card with
// the new choice and the mtime that goes with
// it.
onSelectVariant = { variant ->
chooseVariant(context, entry.key, variant?.path)
onSelectVariant = { component, variant ->
chooseVariant(
context,
entry.key,
component,
variant?.path,
)
val forProject = chosenVariants[entry.key] ?: emptyMap()
chosenVariants =
when (variant) {
null -> chosenVariants - entry.key
else ->
chosenVariants + (entry.key to variant.path)
}
chosenVariants +
(entry.key to
when (variant) {
null -> forProject - component
else ->
forProject +
(component to variant.path)
})
},
serviceBusy = serviceBusy[entry.key],
onServiceAction = { component, action, purge ->
@@ -1151,25 +1191,25 @@ private fun ForcePullDialog(entry: ManifestEntry, onDismiss: () -> Unit, onForce
@Composable
private fun AppCard(
entry: ManifestEntry,
installedLastUpdateTimeMillis: Long?,
installedSizeBytes: Long?,
/** By component name, for the project's own APKs. */
installedTimes: Map<String, Long?>,
installedSizes: Map<String, Long?>,
chosenVariants: Map<String, String>,
cardState: CardState?,
onUpdate: (ManifestEntry) -> Unit,
onUpdate: (ManifestEntry, component: String) -> Unit,
onPull: () -> Unit,
onRebuild: () -> Unit,
onRefresh: () -> Unit,
onSettings: (gitIpv4: Boolean) -> Unit,
onApprove: () -> Unit,
onRemove: () -> Unit,
chosenVariantPath: String?,
onSelectVariant: (ApkVariant?) -> Unit,
onSelectVariant: (component: String, ApkVariant?) -> Unit,
// Which component this card is running a service action for, if any --
// so the one being acted on is the one that shows it, rather than
// every row going quiet together.
serviceBusy: String?,
onServiceAction: (component: String, action: String, purge: Purge) -> Unit,
) {
val upToDate = isUpToDate(entry, installedLastUpdateTimeMillis, chosenVariantPath)
var settingsOpen by remember { mutableStateOf(false) }
// Until the build step this project asks for has been accepted, the
// card is about that request and nothing else: no size, no components,
@@ -1178,18 +1218,6 @@ private fun AppCard(
// decline by getting rid of the card.
val awaitingApproval = entry.pendingDeclaration != null
// What the download would cost, and what it replaces. Belongs to the
// APK the same way the Update button does, and travels with it.
val sizeText =
if (awaitingApproval) null
else
when {
!entry.built -> null
!upToDate && installedSizeBytes != null ->
"${formatSize(installedSizeBytes)} \u2192 ${formatSize(entry.size)}"
else -> formatSize(entry.size)
}
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
// The two corner controls belong to the card, not to its title,
@@ -1207,7 +1235,18 @@ private fun AppCard(
// edge with nothing in the way.
Box(Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.Top, modifier = Modifier.fillMaxWidth()) {
AppIcon(entry.packageName, Modifier.align(Alignment.CenterVertically))
// The project's icon is its client's, and only when
// it has exactly one: a project building two of them
// has no single answer, and showing the first would
// label the whole card with one of its two apps.
// AppIcon draws its own placeholder for null, which is
// what a project with two gets -- the same mark a
// project with nothing built yet gets, because in both
// cases there is no one icon to show.
AppIcon(
entry.components.mapNotNull { it.apk?.packageName }.singleOrNull(),
Modifier.align(Alignment.CenterVertically),
)
Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f)) {
// The one line that makes room for them: they are beside
@@ -1335,14 +1374,34 @@ private fun AppCard(
entry.components
.sortedBy { it.isServer }
.forEach { component ->
val installed = installedTimes[component.name]
val installedSize = installedSizes[component.name]
val chosenVariantPath = chosenVariants[component.name]
val upToDate =
component.apk?.let {
isUpToDate(it, installed, chosenVariantPath)
} == true
ComponentCard(
entryKey = entry.key,
component = component,
packageName = entry.packageName,
// An APK's size sits where a server's state
// does: the one thing worth knowing about it
// besides its name.
sizeText = sizeText.takeIf { !component.isServer },
packageName = component.apk?.packageName,
// What the download would cost, and what it
// replaces. An APK's size sits where a
// server's state does: the one thing worth
// knowing about it besides its name.
sizeText =
component.apk
?.takeIf { it.built }
?.let { apk ->
when {
!upToDate && installedSize != null ->
"${formatSize(installedSize)} \u2192 " +
formatSize(apk.size)
else -> formatSize(apk.size)
}
},
chosenVariantPath = chosenVariantPath,
onSelectVariant = { onSelectVariant(component.name, it) },
// This app reaches the server through this server.
// Stopping or uninstalling it is the one action
// here that cannot be undone from the phone.
@@ -1370,12 +1429,12 @@ private fun AppCard(
// how far along it is: a bar reports on
// the control above it.
UpdateButton(
built = entry.built,
built = component.apk?.built == true,
needsBuild = entry.needsBuild,
installed = installedLastUpdateTimeMillis != null,
installed = installed != null,
upToDate = upToDate,
cardState = cardState,
onUpdate = { onUpdate(entry) },
onUpdate = { onUpdate(entry, component.name) },
onPull = onPull,
)
ApkProgress(cardState)
@@ -1483,26 +1542,6 @@ private fun AppCard(
BuildProgress("Building", cardState.status)
}
// Only while the old app is actually still there. The server
// remembers the name it was renamed from; whether anything
// answers to it is this device's question, and asking it here
// is what saves the server needing to be told when it stops
// being true.
val cardContext = LocalContext.current
val orphan = entry.previousPackageName?.takeIf { isInstalled(cardContext, it) }
if (orphan != null) {
Spacer(Modifier.height(4.dp))
Text(
"Renamed from $orphan. Android treats that as a different " +
"app, so it is still installed and nothing will replace it.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = { cardContext.startActivity(uninstallIntent(orphan)) }) {
Text("Remove the old app")
}
}
// What is left to say about the card once its components have
// said their own part: a failure, or that there is no build to
// talk about yet. Progress is not here -- it belongs beside the
@@ -1545,23 +1584,6 @@ private fun AppCard(
)
}
}
// Only worth a row when there is actually a choice, which is
// rare -- the usual case is a single debug build, and an empty
// row here was leaving a band of space at the foot of every
// card for a control almost none of them have.
//
// And not while a declaration is waiting either: this picks
// which build gets installed, on a card that is offering no
// way to install one.
if (entry.variants.size > 1 && !awaitingApproval) {
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth(),
) {
VariantPicker(entry.variants, chosenVariantPath, onSelectVariant)
}
}
}
}
@@ -1918,6 +1940,9 @@ private fun ComponentCard(
/** The package an APK component installs, for its icon. */
packageName: String?,
sizeText: String?,
/** Which of this component's builds this device is pinned to, if any. */
chosenVariantPath: String? = null,
onSelectVariant: (ApkVariant?) -> Unit = {},
isOwnServer: Boolean,
/** This component's part of a build in progress, if it has one. */
build: ComponentBuild?,
@@ -2148,6 +2173,49 @@ private fun ComponentCard(
Spacer(Modifier.height(6.dp))
ComponentBuildProgress(it)
}
// Only while the old app is actually still there. The build
// machine remembers the name this component was renamed from;
// whether anything answers to it is this device's question,
// and asking it here is what saves the server needing to be
// told when it stops being true.
//
// In this component's card rather than the project's: with two
// clients, only one of them was renamed, and the offer has to
// sit with the one it is about.
val cardContext = LocalContext.current
val orphan = component.apk?.previousPackageName?.takeIf { isInstalled(cardContext, it) }
if (orphan != null) {
Spacer(Modifier.height(4.dp))
Text(
"Renamed from $orphan. Android treats that as a different " +
"app, so it is still installed and nothing will replace it.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = { cardContext.startActivity(uninstallIntent(orphan)) }) {
Text("Remove the old app")
}
}
// Only worth a row when there is actually a choice, which is
// rare -- the usual case is a single debug build, and an empty
// row here was leaving a band of space at the foot of every
// card for a control almost none of them have.
//
// Beside the build it picks, which is what makes it answerable
// for a project with two clients: the choice is this
// component's, and a picker at the foot of the card could only
// have been the project's.
val variants = component.apk?.variants.orEmpty()
if (variants.size > 1) {
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth(),
) {
VariantPicker(variants, chosenVariantPath, onSelectVariant)
}
}
}
}
@@ -2515,12 +2583,12 @@ private fun CardState?.isBuilding(): Boolean =
this is CardState.Pulling || this is CardState.Preparing || this is CardState.Rebuilding
private fun isUpToDate(
entry: ManifestEntry,
apk: ComponentApk,
installedLastUpdateTimeMillis: Long?,
chosenVariantPath: String?,
): Boolean =
installedLastUpdateTimeMillis != null &&
installedLastUpdateTimeMillis >= entry.mtimeMillisFor(chosenVariantPath)
installedLastUpdateTimeMillis >= apk.mtimeMillisFor(chosenVariantPath)
internal fun formatSize(bytes: Long): String {
val mb = bytes / 1024.0 / 1024.0
@@ -11,8 +11,14 @@ import android.content.Context
* the choice lives here and travels with the download request, rather than
* being written into the server's config.
*
* Keyed by the project key, which the server promises never to change: it
* is the same identifier the downloaded file is named after.
* Keyed by the project *and the component*, because a project can build
* two clients: pinning one of them to a release build has nothing to say
* about the other, and a key with only the project in it would have made
* the second component inherit the first's choice.
*
* The project key is the identifier the server promises never to change,
* and a component's name is fixed by the declaration this machine
* accepted, so a stored choice keeps meaning what it meant.
*
* The path is the server's, not this device's, and is checked there against
* the builds it can actually see. Nothing here can name a file into
@@ -22,22 +28,38 @@ import android.content.Context
private const val PREFS_NAME = "variants"
/** The build [key] is pinned to on this device, or null for "the newest". */
fun chosenVariant(context: Context, key: String): String? =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).getString(key, null)
/**
* One stored preference's name. A component's name cannot contain a slash -- it is a RON identifier
* -- so nothing else can collide with a project key that contains one.
*/
private fun slot(key: String, component: String) = "$key/$component"
/** The build one component is pinned to on this device, or null for "the newest". */
fun chosenVariant(context: Context, key: String, component: String): String? =
context
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(slot(key, component), null)
/** Passing null goes back to "whatever is newest", which is the default. */
fun chooseVariant(context: Context, key: String, path: String?) {
fun chooseVariant(context: Context, key: String, component: String, path: String?) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
if (path == null) {
prefs.edit().remove(key).apply()
prefs.edit().remove(slot(key, component)).apply()
} else {
prefs.edit().putString(key, path).apply()
prefs.edit().putString(slot(key, component), path).apply()
}
}
/**
* Forgets a project's choice, for one being removed -- otherwise a key reused by a later project
* would inherit a preference nobody set.
* Forgets every choice made for a project being removed -- otherwise a key reused by a later
* project would inherit preferences nobody set.
*
* Every component at once, because the card that is going away is the only thing that knew which
* components it had.
*/
fun forgetVariant(context: Context, key: String) = chooseVariant(context, key, null)
fun forgetVariants(context: Context, key: String) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val editor = prefs.edit()
prefs.all.keys.filter { it == key || it.startsWith("$key/") }.forEach(editor::remove)
editor.apply()
}