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
@@ -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)
}
}