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:
co-authored by
Claude Opus 5
parent
2ce405c540
commit
08360b75e4
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user