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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user