diff --git a/.gitignore b/.gitignore index ab88d40..ec997ae 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 86f908a..5552a5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 1e129b9..245b365 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f7f0072..6ebbfad 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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.", + ) + } } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt index fae308b..10b6358 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt @@ -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, diff --git a/app/src/main/java/com/ponzischeme89/memby/update/UpdateChecker.kt b/app/src/main/java/com/ponzischeme89/memby/update/UpdateChecker.kt index abac2f8..ee13c75 100644 --- a/app/src/main/java/com/ponzischeme89/memby/update/UpdateChecker.kt +++ b/app/src/main/java/com/ponzischeme89/memby/update/UpdateChecker.kt @@ -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(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 + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/update/UpdateModels.kt b/app/src/main/java/com/ponzischeme89/memby/update/UpdateModels.kt index 321f44b..76e37a4 100644 --- a/app/src/main/java/com/ponzischeme89/memby/update/UpdateModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/update/UpdateModels.kt @@ -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 = "", +) diff --git a/app/src/test/java/com/ponzischeme89/memby/update/UpdateSourceTest.kt b/app/src/test/java/com/ponzischeme89/memby/update/UpdateSourceTest.kt new file mode 100644 index 0000000..670aacd --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/update/UpdateSourceTest.kt @@ -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( + """{"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) + } +} diff --git a/dist/template/index.html b/dist/template/index.html new file mode 100644 index 0000000..056eb08 --- /dev/null +++ b/dist/template/index.html @@ -0,0 +1,101 @@ + + + + + +Memby for Android TV + + + +
+
+

Memby

+

An Emby client for Android TV

+
Version {{VERSION}} · {{DATE}}
+
+ + Download for Android TV +

{{APK_NAME}} · {{SIZE}}

+ +
+

Installing on a TV

+

+ TVs have no easy way to type a web address, so the usual route is the free + Downloader app (AFTV News) from your TV's app store. Open it and enter: +

+ {{APK_URL}} +

+ The TV will ask permission to install apps from Downloader the first time — allow it, + then come back and the install continues. +

+
+ +
+

Installing from a computer

+
    +
  1. Enable Developer options and USB/network debugging on the TV.
  2. +
  3. Download the APK above.
  4. +
  5. Run adb connect <tv-ip> then adb install {{APK_NAME}}.
  6. +
+
+ +
+

What's new in {{VERSION}}

+

{{NOTES}}

+
+ +
+

Updates

+

+ Memby checks here for new versions. In the app, open Settings and set the + update URL to: +

+ {{BASE_URL}}/latest.json +

+ After that, Check for updates downloads and installs new versions on the + TV itself — no computer needed. +

+
+ +
Memby · by ponzischeme89
+
+ + diff --git a/release.ps1 b/release.ps1 new file mode 100644 index 0000000..f5144bd --- /dev/null +++ b/release.ps1 @@ -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-.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"