diff --git a/README.md b/README.md index cfb740f7..d1b28b90 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A discord-like nostr client based on the idea of "relays as groups". Supports NI - **Web** — [app.flotilla.social](https://app.flotilla.social), installable as a PWA - **Android** — [Google Play](https://play.google.com/store/apps/details?id=social.flotilla) +- **Android APK** — [releases](https://gitea.coracle.social/coracle/flotilla/releases), see [Android releases](#android-releases) - **iOS** — [App Store](https://apps.apple.com/us/app/flotilla-chat/id6741344107) - **Your own server** — see [Deployment](#deployment) @@ -153,6 +154,34 @@ Use the AppImage or installed executable rather than the installer. This checks local assets, navigation, workers, and CSP using a disposable profile. Installation, reboot, and uninstall still require target-OS testing. +## Android releases + +Signed APKs are attached to releases on the +[releases page](https://gitea.coracle.social/coracle/flotilla/releases), so Android users can +install and update outside an app store. + +Bump the version, write its `CHANGELOG.md` section and push the matching tag, then: + +```sh +pnpm run release:android +GITEA_TOKEN=... pnpm run publish:android +``` + +`publish:android` creates the release for the tag, takes its notes from the changelog, and attaches +the APK as `flotilla-.apk`, replacing any existing asset of that name. It reads the +repository and the APK path from `zapstore.yaml`, so a release and a zapstore publish ship the same +file. + +### Obtainium + +[Obtainium](https://obtainium.imranr.dev/) installs and updates Android apps from their release +pages. Gitea and Forgejo share a release API, so it works against this repository: + +- App source URL: `https://gitea.coracle.social/coracle/flotilla` +- Override source: `Forgejo (Codeberg)` + +Obtainium reports the git tag as the version. + ## Deployment To run your own Flotilla, it's as simple as: diff --git a/package.json b/package.json index 3a6395ef..ff2a984b 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,11 @@ "build:server": "vite build --config vite.config.server.ts", "start": "node server.js", "release:android": "./scripts/build.sh && cap build android --androidreleasetype APK --signing-type apksigner", + "publish:android": "node scripts/publish-android-release.mjs", "bump": "node scripts/bump-version.mjs", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "lint": "prettier --check src e2e packages playwright.config.ts capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/dev-desktop.mjs scripts/package-desktop.mjs && eslint src e2e packages capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/dev-desktop.mjs scripts/package-desktop.mjs", + "lint": "prettier --check src e2e packages playwright.config.ts capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/dev-desktop.mjs scripts/package-desktop.mjs scripts/publish-android-release.mjs && eslint src e2e packages capacitor.config.ts electron/*.ts electron/electron-builder.config.mjs scripts/dev-desktop.mjs scripts/package-desktop.mjs scripts/publish-android-release.mjs", "test": "playwright test", "test:desktop": "playwright test --config e2e/desktop/playwright.config.ts", "test:ui": "playwright test --ui", diff --git a/scripts/publish-android-release.mjs b/scripts/publish-android-release.mjs new file mode 100644 index 00000000..43e85d53 --- /dev/null +++ b/scripts/publish-android-release.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +import {readFile} from "node:fs/promises" + +const read = path => readFile(new URL(path, import.meta.url), "utf-8") + +const token = process.env.GITEA_TOKEN +const {name, version} = JSON.parse(await read("../package.json")) +const changelog = await read("../CHANGELOG.md") +const zapstore = await read("../zapstore.yaml") + +const zapstoreField = key => { + const match = zapstore.match(new RegExp(`^${key}:\\s*(\\S+)\\s*$`, "m")) + + if (!match) { + throw new Error(`zapstore.yaml is missing ${key}`) + } + + return match[1] +} + +const repository = new URL(zapstoreField("repository")) +const base = `${repository.origin}/api/v1/repos${repository.pathname}` + +const api = async (method, path, {body, allow404} = {}) => { + const multipart = body instanceof FormData + const response = await fetch(base + path, { + method, + headers: { + Authorization: `token ${token}`, + ...(body && !multipart ? {"Content-Type": "application/json"} : {}), + }, + body: multipart ? body : body && JSON.stringify(body), + }) + + if (response.status === 404 && allow404) { + return undefined + } + + if (!response.ok) { + throw new Error(`${method} ${path} responded ${response.status}: ${await response.text()}`) + } + + return response.status === 204 ? undefined : response.json() +} + +if (!token) { + throw new Error("Set GITEA_TOKEN to a token with write access to the repository") +} + +const lines = changelog.split("\n") +const heading = lines.indexOf(`# ${version}`) + +if (heading < 0) { + throw new Error(`CHANGELOG.md has no "# ${version}" section`) +} + +const remainder = lines.slice(heading + 1) +const nextHeading = remainder.findIndex(line => line.startsWith("# ")) +const notes = (nextHeading < 0 ? remainder : remainder.slice(0, nextHeading)).join("\n").trim() +const apk = await readFile(new URL(`../${zapstoreField("release_source")}`, import.meta.url)) + +if (!(await api("GET", `/tags/${version}`, {allow404: true}))) { + throw new Error(`${repository} has no ${version} tag; push it before publishing`) +} + +const release = + (await api("GET", `/releases/tags/${version}`, {allow404: true})) ?? + (await api("POST", "/releases", {body: {tag_name: version, name: version, body: notes}})) + +const filename = `${name}-${version}.apk` +const existing = release.assets?.find(asset => asset.name === filename) + +if (existing) { + await api("DELETE", `/releases/${release.id}/assets/${existing.id}`) +} + +const form = new FormData() + +form.append("attachment", new Blob([apk]), filename) + +const asset = await api( + "POST", + `/releases/${release.id}/assets?name=${encodeURIComponent(filename)}`, + {body: form}, +) + +console.log(asset.browser_download_url)