Self-hosted APK distribution

Memby is handed out from a NAS or web server rather than a store, so:

- UpdateChecker gains a static-manifest source alongside Gitea. A .json
  update URL is read as a manifest ({version, apkUrl, notes}); anything
  else is still treated as a Gitea host. apkUrl may be relative and is
  resolved against the manifest's own URL.
- Release signing config reads memby.keystore from local.properties or
  the environment. Without it the build still succeeds but warns loudly:
  an unsigned APK will not install, and a changed key forces every user
  to uninstall before they can update.
- release.ps1 bumps the version, runs tests, builds a signed APK and
  assembles dist/out/ (landing page, latest.json, versioned APK) ready to
  copy onto the NAS.
- Keystores and dist/out are gitignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ponzischeme89
2026-07-27 08:22:55 +12:00
co-authored by Claude Opus 5
parent 2ce405c540
commit 08360b75e4
10 changed files with 534 additions and 8 deletions
@@ -256,18 +256,20 @@ fun SettingsSheet(
if (editableServer) {
SheetTextField(
label = "Gitea URL",
// A .json URL is a static manifest (a file on a NAS or web
// server); anything else is treated as a Gitea host.
label = "Update URL (…/latest.json, or a Gitea host)",
value = baseUrl,
onValueChange = { baseUrl = it; status = null },
keyboardType = KeyboardType.Uri,
)
SheetTextField(
label = "Repository (owner/repo)",
label = "Repository (owner/repo) — Gitea only",
value = repoPath,
onValueChange = { repoPath = it; status = null },
)
SheetTextField(
label = "Access token",
label = "Access token (optional)",
value = token,
onValueChange = { token = it; status = null },
isPassword = true,
@@ -47,12 +47,72 @@ class UpdateChecker(private val context: Context) {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
}.getOrNull() ?: "?"
suspend fun check(baseUrl: String, repo: String, token: String): UpdateStatus =
/**
* Checks for a newer build.
*
* Two sources, chosen by what the update URL points at:
*
* - a **manifest** (`…/latest.json`) — a static file on any web server or NAS. The
* simplest thing that works: drop an APK and a JSON file next to it.
* - a **Gitea host** — the repo's latest release, with its first `.apk` asset.
*/
suspend fun check(baseUrl: String, repo: String, token: String): UpdateStatus {
val url = baseUrl.trim().trimEnd('/')
if (url.isEmpty()) {
return UpdateStatus.Error("Set the update URL first.")
}
return if (isManifestUrl(url)) {
checkManifest(url, token)
} else {
checkGitea(url, repo, token)
}
}
private suspend fun checkManifest(manifestUrl: String, token: String): UpdateStatus =
withContext(Dispatchers.IO) {
val manifest = runCatching {
val req = Request.Builder().url(manifestUrl).apply {
// Optional: a NAS behind basic auth or a token-guarded path.
if (token.isNotBlank()) header("Authorization", "Bearer ${token.trim()}")
// Manifests are small and change on release; never serve a stale one
// from a proxy cache.
header("Cache-Control", "no-cache")
}.build()
http.newCall(req).execute().use { resp ->
if (!resp.isSuccessful) {
return@withContext UpdateStatus.Error(
when (resp.code) {
401, 403 -> "Update check unauthorized — check the access token."
404 -> "No update manifest found at that URL."
else -> "Update server error (${resp.code})."
},
)
}
json.decodeFromString<UpdateManifest>(resp.body?.string().orEmpty())
}
}.getOrElse {
return@withContext UpdateStatus.Error("Couldn't reach the update server.")
}
if (manifest.version.isBlank() || manifest.apkUrl.isBlank()) {
return@withContext UpdateStatus.Error("The update manifest is incomplete.")
}
return@withContext if (isNewer(manifest.version, installedVersion)) {
UpdateStatus.Available(
version = normalizeVersion(manifest.version),
apkUrl = resolveApkUrl(manifestUrl, manifest.apkUrl),
notes = manifest.notes.trim(),
)
} else {
UpdateStatus.UpToDate(installedVersion)
}
}
private suspend fun checkGitea(host: String, repo: String, token: String): UpdateStatus =
withContext(Dispatchers.IO) {
val host = baseUrl.trim().trimEnd('/')
val repoPath = repo.trim().trim('/')
if (host.isEmpty() || repoPath.isEmpty()) {
return@withContext UpdateStatus.Error("Set the Gitea URL and repository first.")
if (repoPath.isEmpty()) {
return@withContext UpdateStatus.Error("Set the repository (owner/repo) first.")
}
val url = "$host/api/v1/repos/$repoPath/releases/latest"
@@ -153,3 +213,31 @@ class UpdateChecker(private val context: Context) {
private fun normalizeVersion(v: String): String = v.trim().trimStart('v', 'V')
}
/**
* A `.json` update URL means "static manifest"; anything else is treated as a Gitea host.
* Chosen by URL shape rather than a mode switch: one fewer setting to get wrong on a TV
* remote, and the two forms are unambiguous in practice.
*/
internal fun isManifestUrl(url: String): Boolean =
url.substringBefore('?').trimEnd('/').endsWith(".json", ignoreCase = true)
/**
* Resolves a manifest's `apkUrl` against the manifest's own location, so a manifest can
* say `"memby-0.1.53.apk"` and keep working when the folder moves or the host is reached
* by a different name.
*/
internal fun resolveApkUrl(manifestUrl: String, apkUrl: String): String {
val apk = apkUrl.trim()
if (apk.startsWith("http://", true) || apk.startsWith("https://", true)) return apk
val base = manifestUrl.substringBefore('?')
return if (apk.startsWith("/")) {
// Root-relative: keep scheme and host, replace the path.
val schemeEnd = base.indexOf("://").takeIf { it >= 0 }?.plus(3) ?: return apk
val hostEnd = base.indexOf('/', schemeEnd).takeIf { it >= 0 } ?: base.length
base.substring(0, hostEnd) + apk
} else {
base.substringBeforeLast('/', "") + "/" + apk
}
}
@@ -19,3 +19,25 @@ data class GiteaAsset(
@SerialName("name") val name: String = "",
@SerialName("browser_download_url") val browserDownloadUrl: String = "",
)
/**
* A plain update manifest, for hosting APKs on a web server or NAS rather than a Gitea
* release. Point the update URL at the manifest itself:
*
* ```json
* {
* "version": "0.1.53",
* "apkUrl": "https://nas.example.com/memby/memby-0.1.53.apk",
* "notes": "What changed in this build"
* }
* ```
*
* [apkUrl] may be relative ("memby-0.1.53.apk"), in which case it is resolved against
* the manifest's own URL — so moving the whole folder needs no edit.
*/
@Serializable
data class UpdateManifest(
val version: String = "",
val apkUrl: String = "",
val notes: String = "",
)
@@ -0,0 +1,76 @@
package com.ponzischeme89.memby.update
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The update source is chosen by URL shape, and a manifest's apkUrl may be relative.
* Both are easy to get subtly wrong and would strand every TV on an old build.
*/
class UpdateSourceTest {
@Test
fun `a json url is a static manifest`() {
assertTrue(isManifestUrl("https://nas.example.com/memby/latest.json"))
assertTrue(isManifestUrl("https://nas.example.com/memby/latest.JSON"))
assertTrue(isManifestUrl("https://nas.example.com/memby/latest.json?v=2"))
}
@Test
fun `a bare host is treated as gitea`() {
assertFalse(isManifestUrl("https://g.sublogue.com"))
assertFalse(isManifestUrl("https://g.sublogue.com/"))
// A folder that merely contains json elsewhere is not a manifest.
assertFalse(isManifestUrl("https://nas.example.com/json"))
}
@Test
fun `an absolute apk url is used as-is`() {
val resolved = resolveApkUrl(
"https://nas.example.com/memby/latest.json",
"https://cdn.example.com/memby-0.1.54.apk",
)
assertEquals("https://cdn.example.com/memby-0.1.54.apk", resolved)
}
@Test
fun `a relative apk url resolves next to the manifest`() {
val resolved = resolveApkUrl(
"https://nas.example.com/memby/latest.json",
"memby-0.1.54.apk",
)
assertEquals("https://nas.example.com/memby/memby-0.1.54.apk", resolved)
}
@Test
fun `a root-relative apk url keeps the host`() {
val resolved = resolveApkUrl(
"https://nas.example.com/memby/latest.json",
"/downloads/memby-0.1.54.apk",
)
assertEquals("https://nas.example.com/downloads/memby-0.1.54.apk", resolved)
}
@Test
fun `a query string on the manifest does not leak into the apk url`() {
val resolved = resolveApkUrl(
"https://nas.example.com/memby/latest.json?nocache=1",
"memby-0.1.54.apk",
)
assertEquals("https://nas.example.com/memby/memby-0.1.54.apk", resolved)
}
@Test
fun `manifest decodes with the field names the release script writes`() {
val json = kotlinx.serialization.json.Json { ignoreUnknownKeys = true }
val manifest = json.decodeFromString<UpdateManifest>(
"""{"version":"0.1.54","apkUrl":"memby-0.1.54.apk","notes":"Faster home screen"}""",
)
assertEquals("0.1.54", manifest.version)
assertEquals("memby-0.1.54.apk", manifest.apkUrl)
assertEquals("Faster home screen", manifest.notes)
}
}