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
+6
View File
@@ -18,3 +18,9 @@ local.properties
# Holds the Postgres password, admin token and Emby address.
.env
/server/bin/
# Release artefacts and signing material. The keystore must never be committed:
# whoever holds it can publish an update that installs over Memby.
/dist/out/
*.jks
*.keystore
+14 -1
View File
@@ -71,7 +71,20 @@ authorities all derive from it.
**Versioning.** `versionCode` is derived from `versionName`: `major*10000 + minor*100 +
patch` (0.1.53 → 153). Bump both together — the in-app updater compares `versionName`,
while Android refuses an APK whose `versionCode` went backwards.
while Android refuses an APK whose `versionCode` went backwards. `release.ps1 -Version`
rewrites both, so prefer it over editing the build file by hand.
**Releases.** APKs are self-hosted (NAS or any web server), not on a store. `release.ps1`
builds a signed APK and assembles `dist/out/``index.html` (landing page from
`dist/template/`), `latest.json` (the manifest the app polls) and the versioned APK.
Release signing reads `memby.keystore` and friends from `local.properties`; with no
keystore the build still succeeds but emits an unsigned APK and logs a warning. The key
matters more than the code: Android identifies an app by applicationId **plus** signing
key, so a changed key forces every user to uninstall and reinstall.
`UpdateChecker` supports two sources, chosen by URL shape in `isManifestUrl` — a `.json`
URL is a static manifest, anything else is a Gitea host. `resolveApkUrl` lets a manifest
use a relative `apkUrl`. Both are unit-tested in `UpdateSourceTest`.
## Architecture
+60
View File
@@ -119,6 +119,66 @@ server is a property change plus a reinstall, with no user action. Leaving the p
3. **Preview screensaver** to test it, or **Set as system screensaver** to open the TV's
screensaver settings and choose "Memby Screensaver".
## Distributing builds
Memby is handed out as an APK from your own web server or NAS, and updates itself from
the same folder.
### One-time: a signing key
Android identifies an app by `applicationId` **plus signing key**. Every update must be
signed with the *same* key, or the TV rejects it as a different app. Lose the key and
every user has to uninstall and reinstall.
```powershell
keytool -genkeypair -v -keystore memby-release.jks -alias memby `
-keyalg RSA -keysize 4096 -validity 10000
```
Keep the `.jks` somewhere backed up and **outside the repo** (`*.jks` is gitignored), then
point `local.properties` at it — also gitignored:
```properties
memby.keystore=C:/keys/memby-release.jks
memby.keystorePassword=
memby.keyAlias=memby
memby.keyPassword=
```
Without these, `assembleRelease` still builds but the APK is unsigned and will not
install. The build prints a warning saying so.
### Each release
```powershell
.\release.ps1 -Version 0.1.54 -Notes "Faster home screen" -BaseUrl https://nas.example.com/memby
```
That bumps `versionName`/`versionCode`, runs the tests, builds a signed APK, and fills
`dist/out/` with:
```
index.html the page people are sent to
latest.json the update manifest the app polls
memby-0.1.54.apk the build
```
Copy those to the folder the NAS serves. Old APKs can stay alongside — only `latest.json`
decides what the app offers, so rolling back is editing one file.
### How TVs update themselves
In Memby's Settings, set the update URL to `https://nas.example.com/memby/latest.json`.
**Check for updates** then downloads and installs on the TV, no computer involved. A URL
ending in `.json` is read as a static manifest; anything else is treated as a Gitea host
(`/api/v1/repos/{owner}/{repo}/releases/latest`), so either source works.
The manifest's `apkUrl` may be relative (`memby-0.1.54.apk`) and is resolved against the
manifest's own URL, so the folder keeps working if the NAS is reached by another name.
First install on each TV still has to be manual — the **Downloader** app pointed at the
landing page is the usual route, and the page explains it.
## Upgrading to v0.1.53
The package and install identity both became `com.ponzischeme89.memby` in this release
+41
View File
@@ -1,3 +1,5 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
@@ -13,6 +15,17 @@ val embyServerUrl: String = (project.findProperty("memby.serverUrl") as String?)
// becomes a thin renderer; blank keeps the direct-to-Emby path above.
val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as String?).orEmpty().trim()
/** Reads a key from local.properties, which is gitignored and holds machine secrets. */
val localProperties = Properties().apply {
val file = rootProject.file("local.properties")
if (file.exists()) {
file.inputStream().use { load(it) }
}
}
fun localProperty(key: String): String? =
localProperties.getProperty(key)?.trim()?.takeIf { it.isNotEmpty() }
android {
namespace = "com.ponzischeme89.memby"
compileSdk = 35
@@ -34,6 +47,25 @@ android {
buildConfigField("String", "MEMBY_GATEWAY_URL", "\"${membyGatewayUrl.replace("\"", "\\\"")}\"")
}
// Release signing. Android identifies an app by (applicationId, signing key), so
// every update must be signed with the SAME key — a different one is rejected as a
// different app and forces users to uninstall first. Keep the keystore and these
// credentials off the repo: set them in local.properties (gitignored) or the
// environment. Without them, `assembleRelease` still builds but stays unsigned.
val keystorePath = localProperty("memby.keystore") ?: System.getenv("MEMBY_KEYSTORE")
val hasKeystore = !keystorePath.isNullOrBlank() && file(keystorePath).exists()
signingConfigs {
if (hasKeystore) {
create("release") {
storeFile = file(keystorePath!!)
storePassword = localProperty("memby.keystorePassword") ?: System.getenv("MEMBY_KEYSTORE_PASSWORD")
keyAlias = localProperty("memby.keyAlias") ?: System.getenv("MEMBY_KEY_ALIAS") ?: "memby"
keyPassword = localProperty("memby.keyPassword") ?: System.getenv("MEMBY_KEY_PASSWORD")
}
}
}
buildTypes {
release {
isMinifyEnabled = false
@@ -41,6 +73,15 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
if (hasKeystore) {
signingConfig = signingConfigs.getByName("release")
} else {
logger.warn(
"Memby: no release keystore configured (memby.keystore). " +
"assembleRelease will produce an UNSIGNED apk that cannot be installed. " +
"See README > Distributing builds.",
)
}
}
}
@@ -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)
}
}
+101
View File
@@ -0,0 +1,101 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Memby for Android TV</title>
<style>
:root {
color-scheme: dark;
--bg: #0b0e11; --panel: #151a20; --line: #232a32;
--text: #e9edf1; --muted: #98a2ac; --accent: #52b54b;
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 40px 20px; background: var(--bg); color: var(--text);
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif;
}
main { max-width: 720px; margin: 0 auto; }
header { text-align: center; margin-bottom: 36px; }
h1 { font-size: 40px; margin: 0 0 6px; letter-spacing: -0.02em; }
.tagline { color: var(--muted); margin: 0; }
.version { display: inline-block; margin-top: 14px; padding: 4px 12px; border-radius: 999px;
background: rgba(82,181,75,.14); color: var(--accent); font-size: 14px; font-weight: 600; }
.download {
display: block; text-align: center; text-decoration: none; margin: 0 auto 10px;
background: var(--accent); color: #06240a; font-size: 19px; font-weight: 700;
padding: 17px 28px; border-radius: 12px; max-width: 420px;
}
.download:hover { filter: brightness(1.07); }
.beneath { text-align: center; color: var(--muted); font-size: 13px; margin: 0 0 36px; }
section { background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
padding: 22px 24px; margin-bottom: 18px; }
h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .09em; color: var(--muted);
margin: 0 0 14px; }
ol { margin: 0; padding-left: 20px; }
ol li { margin-bottom: 10px; }
ol li:last-child { margin-bottom: 0; }
code { background: #0d1116; border: 1px solid var(--line); border-radius: 6px;
padding: 2px 7px; font-size: 14px; word-break: break-all; }
.url { display: block; margin-top: 8px; font-size: 17px; font-weight: 600; color: var(--accent);
background: #0d1116; border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px;
text-align: center; word-break: break-all; }
.note { color: var(--muted); font-size: 14px; margin: 14px 0 0; }
footer { text-align: center; color: var(--muted); font-size: 13px; margin-top: 30px; }
</style>
</head>
<body>
<main>
<header>
<h1>Memby</h1>
<p class="tagline">An Emby client for Android TV</p>
<div class="version">Version {{VERSION}} &middot; {{DATE}}</div>
</header>
<a class="download" href="{{APK_NAME}}" download>Download for Android TV</a>
<p class="beneath">{{APK_NAME}} &middot; {{SIZE}}</p>
<section>
<h2>Installing on a TV</h2>
<p style="margin-top:0">
TVs have no easy way to type a web address, so the usual route is the free
<strong>Downloader</strong> app (AFTV News) from your TV's app store. Open it and enter:
</p>
<span class="url">{{APK_URL}}</span>
<p class="note">
The TV will ask permission to install apps from Downloader the first time — allow it,
then come back and the install continues.
</p>
</section>
<section>
<h2>Installing from a computer</h2>
<ol>
<li>Enable <strong>Developer options</strong> and <strong>USB/network debugging</strong> on the TV.</li>
<li>Download the APK above.</li>
<li>Run <code>adb connect &lt;tv-ip&gt;</code> then <code>adb install {{APK_NAME}}</code>.</li>
</ol>
</section>
<section>
<h2>What's new in {{VERSION}}</h2>
<p style="margin:0">{{NOTES}}</p>
</section>
<section>
<h2>Updates</h2>
<p style="margin-top:0">
Memby checks here for new versions. In the app, open <strong>Settings</strong> and set the
update URL to:
</p>
<span class="url">{{BASE_URL}}/latest.json</span>
<p class="note">
After that, <strong>Check for updates</strong> downloads and installs new versions on the
TV itself — no computer needed.
</p>
</section>
<footer>Memby &middot; by ponzischeme89</footer>
</main>
</body>
</html>
+117
View File
@@ -0,0 +1,117 @@
<#
.SYNOPSIS
Builds a signed release APK and assembles the folder to publish on the NAS.
.DESCRIPTION
Produces dist/out/ containing:
index.html landing page people are sent to
latest.json update manifest the app polls
memby-<version>.apk the build itself
Copy that folder to whatever the NAS serves, keeping the file names. Old APKs can
stay alongside — only latest.json decides what the app offers.
.PARAMETER Version
Version to build, e.g. 0.1.54. Updates versionName/versionCode in
app/build.gradle.kts before building. Omit to build the version already set.
.PARAMETER Notes
What changed, shown both on the landing page and in the app's update prompt.
.PARAMETER BaseUrl
Public URL of the folder on the NAS, e.g. https://nas.example.com/memby.
Used to build absolute links on the landing page.
.EXAMPLE
.\release.ps1 -Version 0.1.54 -Notes "Faster home screen" -BaseUrl https://nas.example.com/memby
#>
param(
[string] $Version,
[string] $Notes = '',
[Parameter(Mandatory = $true)][string] $BaseUrl
)
$ErrorActionPreference = 'Stop'
$root = $PSScriptRoot
$gradleFile = Join-Path $root 'app\build.gradle.kts'
$outDir = Join-Path $root 'dist\out'
$templateDir = Join-Path $root 'dist\template'
$jdkHome = 'C:\Program Files\Android\Android Studio\jbr'
if (Test-Path $jdkHome) { $env:JAVA_HOME = $jdkHome }
# --- version ---------------------------------------------------------------
$gradle = Get-Content -Raw $gradleFile
if ($Version) {
if ($Version -notmatch '^\d+\.\d+\.\d+$') { throw "Version must look like 0.1.54, got '$Version'" }
$parts = $Version.Split('.')
# Same scheme as app/build.gradle.kts: major*10000 + minor*100 + patch.
$code = [int]$parts[0] * 10000 + [int]$parts[1] * 100 + [int]$parts[2]
$gradle = $gradle -replace 'versionCode = \d+', "versionCode = $code"
$gradle = $gradle -replace 'versionName = "[^"]*"', "versionName = `"$Version`""
Set-Content -LiteralPath $gradleFile -Value $gradle -NoNewline
Write-Host "Set version $Version (versionCode $code)" -ForegroundColor Cyan
} else {
if ($gradle -notmatch 'versionName = "([^"]*)"') { throw 'Could not read versionName from app/build.gradle.kts' }
$Version = $Matches[1]
Write-Host "Building existing version $Version" -ForegroundColor Cyan
}
# --- build -----------------------------------------------------------------
& (Join-Path $root 'gradlew.bat') --console=plain clean test assembleRelease
if ($LASTEXITCODE -ne 0) { throw 'Build failed' }
$apk = Join-Path $root 'app\build\outputs\apk\release\app-release.apk'
if (-not (Test-Path $apk)) {
# An unsigned build lands under a different name and cannot be installed.
$unsigned = Join-Path $root 'app\build\outputs\apk\release\app-release-unsigned.apk'
if (Test-Path $unsigned) {
throw "Release APK is UNSIGNED. Configure memby.keystore in local.properties — " +
"an unsigned APK will not install, and a key change breaks updates for existing users."
}
throw "No release APK found at $apk"
}
# --- assemble the publish folder -------------------------------------------
$apkName = "memby-$Version.apk"
$base = $BaseUrl.TrimEnd('/')
if (Test-Path $outDir) { Remove-Item $outDir -Recurse -Force }
New-Item -ItemType Directory -Force $outDir | Out-Null
Copy-Item $apk (Join-Path $outDir $apkName)
# apkUrl stays relative so the folder keeps working if the NAS is reached by another
# name; the app resolves it against the manifest's own URL.
$manifest = [ordered]@{
version = $Version
apkUrl = $apkName
notes = $Notes
} | ConvertTo-Json
Set-Content -LiteralPath (Join-Path $outDir 'latest.json') -Value $manifest -Encoding utf8
$size = '{0:N1} MB' -f ((Get-Item $apk).Length / 1MB)
$page = Get-Content -Raw (Join-Path $templateDir 'index.html')
$page = $page.Replace('{{VERSION}}', $Version).
Replace('{{APK_NAME}}', $apkName).
Replace('{{APK_URL}}', "$base/$apkName").
Replace('{{BASE_URL}}', $base).
Replace('{{SIZE}}', $size).
Replace('{{NOTES}}', $(if ($Notes) { $Notes } else { 'Various improvements.' })).
Replace('{{DATE}}', (Get-Date -Format 'd MMMM yyyy'))
Set-Content -LiteralPath (Join-Path $outDir 'index.html') -Value $page -Encoding utf8
Write-Host ''
Write-Host "Ready to publish:" -ForegroundColor Green
Get-ChildItem $outDir | ForEach-Object { " $($_.Name)" }
Write-Host ''
Write-Host "Copy the contents of dist\out\ to the folder served at $base"
Write-Host "Landing page: $base/"
Write-Host "Update manifest: $base/latest.json"