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
@@ -18,3 +18,9 @@ local.properties
|
|||||||
# Holds the Postgres password, admin token and Emby address.
|
# Holds the Postgres password, admin token and Emby address.
|
||||||
.env
|
.env
|
||||||
/server/bin/
|
/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
|
||||||
|
|||||||
@@ -71,7 +71,20 @@ authorities all derive from it.
|
|||||||
|
|
||||||
**Versioning.** `versionCode` is derived from `versionName`: `major*10000 + minor*100 +
|
**Versioning.** `versionCode` is derived from `versionName`: `major*10000 + minor*100 +
|
||||||
patch` (0.1.53 → 153). Bump both together — the in-app updater compares `versionName`,
|
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
|
## Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
3. **Preview screensaver** to test it, or **Set as system screensaver** to open the TV's
|
||||||
screensaver settings and choose "Memby Screensaver".
|
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
|
## Upgrading to v0.1.53
|
||||||
|
|
||||||
The package and install identity both became `com.ponzischeme89.memby` in this release
|
The package and install identity both became `com.ponzischeme89.memby` in this release
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("org.jetbrains.kotlin.android")
|
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.
|
// becomes a thin renderer; blank keeps the direct-to-Emby path above.
|
||||||
val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as String?).orEmpty().trim()
|
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 {
|
android {
|
||||||
namespace = "com.ponzischeme89.memby"
|
namespace = "com.ponzischeme89.memby"
|
||||||
compileSdk = 35
|
compileSdk = 35
|
||||||
@@ -34,6 +47,25 @@ android {
|
|||||||
buildConfigField("String", "MEMBY_GATEWAY_URL", "\"${membyGatewayUrl.replace("\"", "\\\"")}\"")
|
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 {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
@@ -41,6 +73,15 @@ android {
|
|||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"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) {
|
if (editableServer) {
|
||||||
SheetTextField(
|
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,
|
value = baseUrl,
|
||||||
onValueChange = { baseUrl = it; status = null },
|
onValueChange = { baseUrl = it; status = null },
|
||||||
keyboardType = KeyboardType.Uri,
|
keyboardType = KeyboardType.Uri,
|
||||||
)
|
)
|
||||||
SheetTextField(
|
SheetTextField(
|
||||||
label = "Repository (owner/repo)",
|
label = "Repository (owner/repo) — Gitea only",
|
||||||
value = repoPath,
|
value = repoPath,
|
||||||
onValueChange = { repoPath = it; status = null },
|
onValueChange = { repoPath = it; status = null },
|
||||||
)
|
)
|
||||||
SheetTextField(
|
SheetTextField(
|
||||||
label = "Access token",
|
label = "Access token (optional)",
|
||||||
value = token,
|
value = token,
|
||||||
onValueChange = { token = it; status = null },
|
onValueChange = { token = it; status = null },
|
||||||
isPassword = true,
|
isPassword = true,
|
||||||
|
|||||||
@@ -47,12 +47,72 @@ class UpdateChecker(private val context: Context) {
|
|||||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||||
}.getOrNull() ?: "?"
|
}.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) {
|
withContext(Dispatchers.IO) {
|
||||||
val host = baseUrl.trim().trimEnd('/')
|
|
||||||
val repoPath = repo.trim().trim('/')
|
val repoPath = repo.trim().trim('/')
|
||||||
if (host.isEmpty() || repoPath.isEmpty()) {
|
if (repoPath.isEmpty()) {
|
||||||
return@withContext UpdateStatus.Error("Set the Gitea URL and repository first.")
|
return@withContext UpdateStatus.Error("Set the repository (owner/repo) first.")
|
||||||
}
|
}
|
||||||
val url = "$host/api/v1/repos/$repoPath/releases/latest"
|
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')
|
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("name") val name: String = "",
|
||||||
@SerialName("browser_download_url") val browserDownloadUrl: 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+101
@@ -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}} · {{DATE}}</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<a class="download" href="{{APK_NAME}}" download>Download for Android TV</a>
|
||||||
|
<p class="beneath">{{APK_NAME}} · {{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 <tv-ip></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 · by ponzischeme89</footer>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+117
@@ -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"
|
||||||
Reference in New Issue
Block a user