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
@@ -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
// 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<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.
*
@@ -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