Tell a sideloaded app when a newer build exists

Nothing updates this app on its own. It is downloaded as an APK from a web
page, so there is no store to notice a release and no background updater to
install one — a person can run a build from months ago indefinitely, through
every fix that came after it, and never find out. That is the part of
sideloading that is genuinely worse than a store, and this is the only thing
that closes it.

The background check asks the RELAY the install is signed in to, not a fixed
address of ours. Pointing every install at flamenetmessenger.com would mean an
app whose whole claim is that it does not phone home checking in with us on a
timer, from every device, including the self-hosted ones that deliberately
never contact us at all. A relay that publishes no manifest simply produces no
prompt.

"Check for updates" in Account is the other half, and the reason the fallback
to our site exists: pressing it is a question the user asked, so answering
"your relay does not publish updates" when we plainly could is unhelpful. It
tries the relay first and names whichever host answered, so it is never a
silent phone-home. It also distinguishes "no newer build" from "could not
check" — reporting silence as good news is how somebody sits on a build with a
fix in it believing otherwise.

The download URL is built from the host that answered and never read from the
manifest. A `url` field would let any relay send its users to an APK on a host
of its choosing; Android's signature check is a backstop, not a reason to build
the hole in front of it. A test asserts a hostile manifest cannot move it, and
fails if the guard is removed.

versionCode is bumped and `scripts/release-manifest.sh` reads it back out of
the built APK, so the manifest and the artifact cannot drift.

⚠️ Nobody already installed sees this: every build before this one has no
check in it, so the first prompt anybody gets is for the build after this one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-29 15:02:59 -04:00
co-authored by Claude Opus 4.8
parent 0e89003cc0
commit 31551f60f2
7 changed files with 641 additions and 2 deletions
+13 -2
View File
@@ -22,8 +22,19 @@ android {
// claiming a lower floor would be claiming something untrue. // claiming a lower floor would be claiming something untrue.
minSdk = 33 minSdk = 33
targetSdk = 35 targetSdk = 35
versionCode = 1 // 🔴 **Bump BOTH on every published build, and never reuse a code.**
versionName = "0.1" // Nothing installs this app but the person holding the phone, so
// `versionCode` is not bookkeeping — it is the only thing that tells an
// existing install a newer build exists. `UpdateCheck` compares this
// against what the relay publishes in `downloads/android-latest.json`,
// and `scripts/release-manifest.sh` reads it back out of the built APK
// to write that file, so the two cannot drift.
//
// ⚠️ Android also refuses to install an APK whose `versionCode` is
// lower than the installed one, so a code that goes backwards makes a
// release uninstallable for everybody already on the higher number.
versionCode = 2
versionName = "0.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -0,0 +1,245 @@
package io.flamenet.messenger.app
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import io.flamenet.client.UpdateCheck
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Telling a hand-installed app that a newer one exists.
*
* 🔴 **This app is not in a store, so nothing else will ever say this.** A
* person who installs the APK and never visits the site again keeps running
* that build forever, including through every fix that came after it. The
* network check lives in [UpdateCheck] and explains why it asks the user's own
* relay rather than an address of ours; this file is the part that decides how
* often to ask and how loudly to say so.
*
* ⚠️ **Nobody already installed will see this.** Every build before this one
* has no update check in it, so the first prompt anybody gets is for the build
* *after* the one that introduces this. That is not a bug to fix later — it is
* inherent, and it means the existing install base still has to be reached the
* way it always was, by telling them.
*/
object AppUpdate {
private const val PREFS = "app-update"
private const val LAST_CHECKED = "last-checked-at"
private const val DISMISSED_CODE = "dismissed-version-code"
/**
* Six hours. Long enough that opening the app repeatedly costs one request
* a quarter of a day, short enough that a fix published in the morning is
* offered the same day.
*/
private const val CHECK_INTERVAL_MS = 6L * 60 * 60 * 1000
private fun prefs(context: Context) =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
/**
* What is actually installed, asked of the package manager.
*
* ⚠️ Not `BuildConfig.VERSION_CODE`. It would need a build feature turned
* on, and more to the point it is a constant compiled into the APK: it
* describes what was *built*, and this needs to describe what is *running*.
* They are the same today and the package manager cannot drift.
*/
fun installedVersionCode(context: Context): Int = runCatching {
val info = context.packageManager.getPackageInfo(context.packageName, 0)
info.longVersionCode.toInt()
}.getOrDefault(0)
fun installedVersionName(context: Context): String = runCatching {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
}.getOrNull() ?: "unknown"
private fun dueForCheck(context: Context): Boolean =
System.currentTimeMillis() - prefs(context).getLong(LAST_CHECKED, 0) > CHECK_INTERVAL_MS
private fun recordCheck(context: Context) =
prefs(context).edit().putLong(LAST_CHECKED, System.currentTimeMillis()).apply()
/**
* Stop offering this particular build.
*
* ⚠️ Recorded per version, not as a "don't ask me again" flag. Somebody who
* declines 0.7 today should still be told about 0.8 next month — a single
* boolean would silence the feature permanently on the first dismissal,
* which is how update prompts end up being useless.
*/
fun dismiss(context: Context, versionCode: Int) =
prefs(context).edit().putInt(DISMISSED_CODE, versionCode).apply()
fun isDismissed(context: Context, versionCode: Int): Boolean =
prefs(context).getInt(DISMISSED_CODE, 0) >= versionCode
/** Hand the download to the browser, which is where an APK install starts. */
fun openDownload(context: Context, url: String) {
runCatching {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(url))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
)
}
}
/**
* Where releases are published when the relay does not publish them.
*
* 🔴 **Only ever reached because somebody pressed the button.** The
* automatic check asks the user's own relay and stops there, so an app that
* is signed in to somebody else's server never contacts us on a timer.
* Pressing "Check for updates" is a different thing: it is a question the
* user asked, and answering "your relay does not publish updates, so I
* cannot tell you" when we plainly could is unhelpful. The screen says
* which host answered, so this is never a silent phone-home.
*/
const val OFFICIAL_SITE = "https://flamenetmessenger.com"
/** What an explicit check found, including which host answered. */
sealed interface CheckResult {
data class Available(val release: UpdateCheck.Release, val source: String) : CheckResult
data class UpToDate(val source: String) : CheckResult
/** Neither host published a manifest, or neither could be reached. */
data object Unknown : CheckResult
}
/**
* The check behind the button: this relay first, then the official site.
*
* ⚠️ Ordered, not raced. The relay is asked first so somebody running their
* own gets their own answer, and the site is only reached for if that
* produced nothing at all — a relay that publishes an OLDER build than we
* do is still the answer for the person who chose that relay.
*/
suspend fun checkNow(context: Context, baseUrl: String?): CheckResult =
withContext(Dispatchers.IO) {
val installed = installedVersionCode(context)
val hosts = listOfNotNull(baseUrl?.takeIf { it.isNotBlank() }, OFFICIAL_SITE)
for (host in hosts) {
val published = UpdateCheck.published(host) ?: continue
recordCheck(context)
return@withContext if (published.versionCode > installed) {
CheckResult.Available(published, hostLabel(host))
} else {
CheckResult.UpToDate(hostLabel(host))
}
}
CheckResult.Unknown
}
/** Just the hostname — a full URL in a sentence reads as an error. */
private fun hostLabel(url: String): String =
runCatching { java.net.URI(url).host }.getOrNull() ?: url
/**
* Ask the relay, off the main thread, no more often than the interval.
*
* ⚠️ The relay ONLY. The fallback to our site belongs to [checkNow], which
* runs because somebody asked; putting it here would make every install
* check in with us on a timer, which is the thing this product says it does
* not do.
*
* Returns null when there is nothing to say, which is the overwhelmingly
* common answer and must stay silent.
*/
suspend fun check(context: Context, baseUrl: String?, force: Boolean = false):
UpdateCheck.Release? {
if (baseUrl.isNullOrBlank()) return null
if (!force && !dueForCheck(context)) return null
val installed = installedVersionCode(context)
return withContext(Dispatchers.IO) {
UpdateCheck.availableUpdate(baseUrl, installed)
}.also { recordCheck(context) }
}
/**
* The check the whole screen hangs off, run once per composition.
*
* ⚠️ Keyed on the relay so that signing into a different one re-asks. The
* answer is that relay's, not a global fact.
*/
@Composable
fun rememberAvailable(baseUrl: String?): UpdateCheck.Release? {
val context = LocalContext.current
var release by remember(baseUrl) { mutableStateOf<UpdateCheck.Release?>(null) }
LaunchedEffect(baseUrl) { release = check(context, baseUrl) }
return release
}
}
/**
* The prompt itself.
*
* ⚠️ A dialog, and only for a version not yet declined. Everything else about
* updates lives quietly in Account — an app that interrupts you on every launch
* to talk about itself trains people to dismiss it without reading, and then
* the one update that mattered gets dismissed too.
*/
@Composable
fun UpdateAvailableDialog(baseUrl: String?) {
val context = LocalContext.current
val release = AppUpdate.rememberAvailable(baseUrl) ?: return
var showing by remember(release.versionCode) {
mutableStateOf(!AppUpdate.isDismissed(context, release.versionCode))
}
if (!showing) return
AlertDialog(
onDismissRequest = { showing = false },
title = { Text("Version ${release.versionName} is out") },
text = {
Text(
buildString {
append(
release.notes.ifBlank {
"A newer build of Flamenet Messenger is published."
},
)
append("\n\nThis app installs by hand rather than through a store, ")
append("so it cannot update itself. Downloading opens your browser; ")
append("installing over the top keeps your messages and your account.")
},
fontSize = 14.sp,
)
},
confirmButton = {
TextButton(
onClick = {
AppUpdate.openDownload(context, release.downloadUrl)
showing = false
},
modifier = Modifier.testTag("update-download"),
) { Text("Download", fontWeight = FontWeight.Medium) }
},
dismissButton = {
TextButton(
onClick = {
// Declining means declining THIS version, permanently. The
// next one asks again.
AppUpdate.dismiss(context, release.versionCode)
showing = false
},
modifier = Modifier.testTag("update-later"),
) { Text("Not now", color = MaterialTheme.colorScheme.onSurfaceVariant) }
},
)
}
@@ -796,6 +796,9 @@ fun ColumnScope.AccountScreen(
} }
} }
SectionBand()
SettingsSection { AppVersionSection(model) }
// ⚠️ **The destructive pair, in their own section.** They confirm in place // ⚠️ **The destructive pair, in their own section.** They confirm in place
// rather than in a dialog: a dialog is dismissed reflexively; a button that // rather than in a dialog: a dialog is dismissed reflexively; a button that
// changes to say what it is about to do is read. // changes to say what it is about to do is read.
@@ -836,6 +839,114 @@ fun ColumnScope.AccountScreen(
* means telling every contact again. That trade is stated on the screen rather * means telling every contact again. That trade is stated on the screen rather
* than left for somebody to discover when a new contact sees initials. * than left for somebody to discover when a new contact sees initials.
*/ */
/**
* Which build this is, and a button to go and find out if it is current.
*
* 🔴 **A sideloaded app has to answer this on demand.** The dialog in
* [UpdateAvailableDialog] can be declined, and should be — but then there has
* to be a place that still knows. This is also the answer to "which version am
* I running", which is the first question any support conversation opens with.
*
* ⚠️ **A button, not an automatic check on entering the screen.** Opening
* Account is not a request to talk to the network, and the background check
* already runs on its own schedule. Pressing this is the user asking, which is
* also the only reason it is allowed to fall back to our site — see
* [AppUpdate.checkNow].
*/
@Composable
private fun ColumnScope.AppVersionSection(model: MessengerViewModel) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val baseUrl = model.site?.baseUrl
val installed = remember { AppUpdate.installedVersionName(context) }
var checking by remember { mutableStateOf(false) }
var result by remember { mutableStateOf<AppUpdate.CheckResult?>(null) }
Text("This app", style = MaterialTheme.typography.titleMedium)
SettingsRow(
label = "Version $installed",
description = "This app installs by hand rather than through a store, so it "
+ "cannot update itself.",
tag = "app-version",
)
KitSecondaryButton(
text = if (checking) "Checking…" else "Check for updates",
onClick = {
scope.launch {
checking = true
result = AppUpdate.checkNow(context, baseUrl)
checking = false
}
},
enabled = !checking,
modifier = Modifier.fillMaxWidth(),
tag = "check-for-updates",
)
// ⚠️ Every branch names the host that answered. "Up to date" without saying
// according to whom is a claim the app cannot actually make — a relay can
// be behind, and on a self-hosted one the answer may have come from our
// site instead.
when (val outcome = result) {
null -> Unit
is AppUpdate.CheckResult.UpToDate -> Text(
"Version $installed is the newest ${outcome.source} publishes.",
fontSize = 12.sp,
color = Palette.SecondaryText,
modifier = Modifier.testTag("update-none"),
)
// 🔴 Says plainly that it could not tell, rather than "up to date".
// A relay that publishes no manifest and a site that could not be
// reached both produce silence, and reporting silence as good news is
// how somebody sits on a build with a fix in it believing otherwise.
is AppUpdate.CheckResult.Unknown -> Text(
"Could not check. Neither this relay nor flamenetmessenger.com said "
+ "which build is current — that is not the same as being up to date.",
fontSize = 12.sp,
color = Palette.SecondaryText,
modifier = Modifier.testTag("update-unknown"),
)
is AppUpdate.CheckResult.Available -> {
val release = outcome.release
Spacer(Modifier.height(Space.sm))
Text(
"Version ${release.versionName} is available from ${outcome.source}.",
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.testTag("update-available"),
)
if (release.notes.isNotBlank()) {
Text(release.notes, fontSize = 12.sp, color = Palette.SecondaryText)
}
Spacer(Modifier.height(Space.sm))
KitPrimaryButton(
text = "Download ${release.versionName}",
onClick = { AppUpdate.openDownload(context, release.downloadUrl) },
modifier = Modifier.fillMaxWidth(),
tag = "download-update",
)
Text(
"Opens your browser. Installing over the top keeps your messages and your "
+ "account — Android asks you to allow installs from the browser the "
+ "first time.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
// For anyone who wants to check the file they got is the file we
// published. Small, because most people will never use it and it
// should not compete with the button.
release.sha256?.let {
Text("SHA-256 $it", fontSize = 10.sp, color = Palette.SecondaryText)
}
}
}
}
/** /**
* The app lock toggle. * The app lock toggle.
* *
@@ -222,6 +222,12 @@ private fun Root(model: MessengerViewModel = viewModel()) {
// already happening. // already happening.
CallBanner(model, current) CallBanner(model, current)
StoryComposerDialog(model, current) StoryComposerDialog(model, current)
// 🔴 **Here, above the screen switch, for the same reason
// the call banner is.** Nothing installs this app but the
// person holding the phone, so the one moment they can be
// told a fix exists is while they have it open — whatever
// screen that happens to be.
UpdateAvailableDialog(model.site?.baseUrl)
if (LiveCallScreen(model, current)) return@Column if (LiveCallScreen(model, current)) return@Column
val chat = current.openChat val chat = current.openChat
@@ -0,0 +1,123 @@
package io.flamenet.client
import java.util.concurrent.TimeUnit
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
/**
* Whether a newer build than this one is published, for an app that is
* installed by hand.
*
* 🔴 **Nothing updates this app on its own.** It is downloaded as an APK from a
* web page, so there is no store to notice a new release and no background
* updater to install it. Without this check a person can run a build from
* months ago indefinitely and never know — and the builds they are missing
* include security fixes. A sideloaded app that cannot tell you it is out of
* date is the part of sideloading that is genuinely worse, and this is the only
* thing that closes it.
*
* 🔴 **Asked of the relay you already talk to, not of a fixed address of ours.**
* The obvious implementation points every install at flamenetmessenger.com, and
* that would mean an app whose whole claim is that it does not phone home
* checking in with us on a timer — from every device, including the self-hosted
* ones that deliberately never contact us at all. Their traffic is their
* server's business. So the manifest is fetched from the relay this install is
* signed in to, which learns nothing it does not already know from the traffic
* it is carrying.
*
* ⚠️ **The consequence, stated rather than hidden:** a relay that publishes no
* manifest produces no prompt. Self-hosters who do not republish the APK get no
* update prompts, which is correct — we do not know what build they are meant
* to be running — but it does mean this cannot be relied on as the only way a
* fix reaches everybody.
*
* 🔴 **The download URL is built here and never read from the manifest.** A
* `url` field in the JSON would let any relay send its users to an APK on a
* host of its choosing, which is a much larger thing to hand a server than
* "tell me a version number". Android's own signature check is the backstop —
* it refuses to install a differently-signed APK over an existing one — but a
* backstop is not a reason to build the hole in front of it.
*/
object UpdateCheck {
/** Where a relay publishes what it considers the current Android build. */
const val MANIFEST_PATH = "downloads/android-latest.json"
/** The APK itself, on the same host. Constructed, never taken from JSON. */
const val APK_PATH = "downloads/flamenet-messenger.apk"
/**
* A published release, as the relay describes it.
*
* @param sha256 the APK's fingerprint, so somebody who wants to verify the
* file they downloaded can. Optional: a manifest without one is still
* usable, it just cannot be checked by hand.
*/
data class Release(
val versionCode: Int,
val versionName: String,
val notes: String,
val sha256: String?,
val downloadUrl: String,
)
/** Short timeouts: this is a background courtesy, never worth a stall. */
private val http = OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.callTimeout(8, TimeUnit.SECONDS)
.build()
/** A manifest is a handful of fields; anything larger is not one. */
private const val MAX_BYTES = 16L * 1024
/**
* What the relay publishes, or null if it publishes nothing usable.
*
* Null for every unusual case — no manifest, a refusal, a timeout, a body
* that is not the JSON expected. None of them are worth telling the user
* about: the failure mode of this whole feature is "no prompt", and a
* messenger that interrupts you to say it could not check for updates has
* made itself the subject.
*/
fun published(baseUrl: String, client: OkHttpClient = http): Release? = runCatching {
val base = baseUrl.trimEnd('/').toHttpUrl()
val url = base.newBuilder().addPathSegments(MANIFEST_PATH).build()
client.newCall(Request.Builder().url(url).get().build()).execute().use { response ->
if (!response.isSuccessful) return null
val body = response.body?.source()?.let { source ->
source.request(MAX_BYTES)
source.buffer.snapshot(minOf(MAX_BYTES, source.buffer.size).toInt()).utf8()
} ?: return null
val json = JSONObject(body)
val code = json.optInt("versionCode", 0)
// A manifest that cannot say which build it describes is not one.
if (code <= 0) return null
Release(
versionCode = code,
versionName = json.optString("versionName").ifEmpty { code.toString() },
notes = json.optString("notes"),
sha256 = json.optString("sha256").takeIf { it.length == 64 },
downloadUrl = base.newBuilder().addPathSegments(APK_PATH).build().toString(),
)
}
}.getOrNull()
/**
* The published release if it is newer than [installedVersionCode], else null.
*
* ⚠️ Strictly greater. Equal is the common case — almost every check finds
* the build the user already has — and a prompt offering to install what is
* already installed teaches people to dismiss these without reading. Older
* is possible too, on a relay that has not been updated in a while, and is
* likewise not something to offer.
*/
fun availableUpdate(
baseUrl: String,
installedVersionCode: Int,
client: OkHttpClient = http,
): Release? = published(baseUrl, client)?.takeIf { it.versionCode > installedVersionCode }
}
@@ -0,0 +1,100 @@
package io.flamenet.client
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The update check for an app nothing else updates.
*
* 🔴 The case worth pinning hardest is the one that is not about versions at
* all: **the download URL must come from the relay's own origin**, whatever the
* manifest says. A relay that could name the APK host could hand its users a
* different app.
*/
class UpdateCheckTest {
private lateinit var server: MockWebServer
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
@AfterTest fun stop() { server.shutdown() }
private fun base() = server.url("/").toString().trimEnd('/')
private fun serve(body: String, code: Int = 200) {
server.enqueue(MockResponse().setResponseCode(code).setBody(body))
}
@Test
fun `a newer published build is offered`() {
serve("""{"versionCode":7,"versionName":"0.7","notes":"Calls ring properly."}""")
val update = UpdateCheck.availableUpdate(base(), installedVersionCode = 3)
assertEquals(7, update?.versionCode)
assertEquals("0.7", update?.versionName)
assertEquals("Calls ring properly.", update?.notes)
}
@Test
fun `the build already installed is not offered`() {
serve("""{"versionCode":7,"versionName":"0.7"}""")
assertNull(UpdateCheck.availableUpdate(base(), installedVersionCode = 7))
}
@Test
fun `a relay behind the installed build is not offered`() {
serve("""{"versionCode":2,"versionName":"0.2"}""")
assertNull(UpdateCheck.availableUpdate(base(), installedVersionCode = 7))
}
@Test
fun `the download url is built from the relay, never taken from the manifest`() {
// A hostile manifest naming somebody else's APK. The field is ignored.
serve(
"""{"versionCode":9,"versionName":"9","url":"https://evil.example/pwn.apk",
"downloadUrl":"https://evil.example/pwn.apk"}""",
)
val update = UpdateCheck.availableUpdate(base(), installedVersionCode = 1)
assertTrue(update!!.downloadUrl.startsWith(base()), update.downloadUrl)
assertTrue(update.downloadUrl.endsWith("/downloads/flamenet-messenger.apk"))
assertTrue("evil.example" !in update.downloadUrl)
}
@Test
fun `a relay that publishes no manifest simply produces no prompt`() {
serve("Not Found", code = 404)
assertNull(UpdateCheck.availableUpdate(base(), installedVersionCode = 1))
}
@Test
fun `a body that is not a manifest produces no prompt`() {
serve("<html>a captive portal, or a typo'd host</html>")
assertNull(UpdateCheck.availableUpdate(base(), installedVersionCode = 1))
}
@Test
fun `a manifest with no version code describes nothing and is ignored`() {
serve("""{"versionName":"0.9","notes":"forgot the code"}""")
assertNull(UpdateCheck.availableUpdate(base(), installedVersionCode = 1))
}
@Test
fun `an unreachable relay produces no prompt rather than an error`() {
server.shutdown()
assertNull(UpdateCheck.availableUpdate(base(), installedVersionCode = 1))
}
@Test
fun `a fingerprint is carried through, and a malformed one is dropped`() {
val sha = "a".repeat(64)
serve("""{"versionCode":9,"sha256":"$sha"}""")
assertEquals(sha, UpdateCheck.availableUpdate(base(), 1)?.sha256)
serve("""{"versionCode":9,"sha256":"tooshort"}""")
assertNull(UpdateCheck.availableUpdate(base(), 1)?.sha256)
}
}
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Write the update manifest for a built APK.
#
# 🔴 The version in the manifest is read back OUT of the APK, never typed. A
# manifest that claims a version the APK is not is worse than no manifest: it
# either offers an update that installs as the same build (and so appears to do
# nothing), or fails to offer one that exists. Reading it from the artifact is
# what makes the two incapable of disagreeing.
#
# Usage: scripts/release-manifest.sh <apk> [notes] > android-latest.json
set -euo pipefail
apk="${1:?usage: release-manifest.sh <apk> [notes]}"
notes="${2:-}"
[ -f "$apk" ] || { echo "no such APK: $apk" >&2; exit 1; }
# aapt2 ships with the build tools; find the newest one rather than pinning a
# version that will be wrong on the next machine.
sdk="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
aapt2="$(find "$sdk/build-tools" -maxdepth 2 -name aapt2 -type f 2>/dev/null | sort -V | tail -1)"
[ -n "$aapt2" ] || { echo "aapt2 not found under $sdk/build-tools" >&2; exit 1; }
badging="$("$aapt2" dump badging "$apk")"
code="$(sed -n "s/.*versionCode='\([0-9]*\)'.*/\1/p" <<<"$badging" | head -1)"
name="$(sed -n "s/.*versionName='\([^']*\)'.*/\1/p" <<<"$badging" | head -1)"
[ -n "$code" ] && [ -n "$name" ] || { echo "could not read version from $apk" >&2; exit 1; }
sha="$(shasum -a 256 "$apk" | cut -d' ' -f1)"
# ⚠️ No `url` field, deliberately. The client builds the download URL from the
# relay it is signed in to and ignores anything the manifest says about where
# the APK lives — a relay that could name the host could hand its users a
# different app. Adding one here would be dead weight at best and a trap for
# the next person to implement a client at worst.
cat <<JSON
{
"versionCode": $code,
"versionName": "$name",
"sha256": "$sha",
"notes": $(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$notes")
}
JSON