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
+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"