diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2b874c9..d44a02d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,8 +22,19 @@ android { // claiming a lower floor would be claiming something untrue. minSdk = 33 targetSdk = 35 - versionCode = 1 - versionName = "0.1" + // πŸ”΄ **Bump BOTH on every published build, and never reuse a code.** + // 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" } diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/AppUpdate.kt b/app/src/main/kotlin/io/flamenet/messenger/app/AppUpdate.kt new file mode 100644 index 0000000..6cea602 --- /dev/null +++ b/app/src/main/kotlin/io/flamenet/messenger/app/AppUpdate.kt @@ -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(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) } + }, + ) +} diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt index 08edc2b..baee42b 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt @@ -796,6 +796,9 @@ fun ColumnScope.AccountScreen( } } + SectionBand() + SettingsSection { AppVersionSection(model) } + // ⚠️ **The destructive pair, in their own section.** They confirm in place // rather than in a dialog: a dialog is dismissed reflexively; a button that // 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 * 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(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. * diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt index af3381f..ab36742 100644 --- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt +++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt @@ -222,6 +222,12 @@ private fun Root(model: MessengerViewModel = viewModel()) { // already happening. CallBanner(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 val chat = current.openChat diff --git a/client/src/main/kotlin/io/flamenet/client/UpdateCheck.kt b/client/src/main/kotlin/io/flamenet/client/UpdateCheck.kt new file mode 100644 index 0000000..c2ca7b6 --- /dev/null +++ b/client/src/main/kotlin/io/flamenet/client/UpdateCheck.kt @@ -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 } +} diff --git a/client/src/test/kotlin/io/flamenet/client/UpdateCheckTest.kt b/client/src/test/kotlin/io/flamenet/client/UpdateCheckTest.kt new file mode 100644 index 0000000..e9841c3 --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/UpdateCheckTest.kt @@ -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("a captive portal, or a typo'd host") + 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) + } +} diff --git a/scripts/release-manifest.sh b/scripts/release-manifest.sh new file mode 100755 index 0000000..0a624db --- /dev/null +++ b/scripts/release-manifest.sh @@ -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 [notes] > android-latest.json +set -euo pipefail + +apk="${1:?usage: release-manifest.sh [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 <