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:
co-authored by
Claude Opus 4.8
parent
0e89003cc0
commit
31551f60f2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user