FeedPug Android client
A minimal Kotlin / Jetpack Compose reader for FeedPug. It authenticates with a
per-user API token using the same device-pairing flow as ../dms:
- In the FeedPug web app, open the account menu → Devices, generate a token.
- The page shows a QR encoding
feedpug://pair?base=<server>&token=<token>. - The app scans the QR (or you open the URI / paste the fields), verifies it
by calling
GET /api/profile, and stores(baseUrl, token)inEncryptedSharedPreferences. - Every request then sends
Authorization: Bearer <token>.
Features (MVP)
- Pair screen: QR scan (ZXing) + manual URL/token entry + verify.
- Timeline: aggregated newsfeed, full-text search over entry bodies, your saved views as filter chips (tap to apply, “All” to clear), unread-only toggle, “load more” keyset pagination, mark-all-read, unpair. Swipe a row left to mark that entry unread.
- Detail: full entry rendered in a (JavaScript-disabled) WebView, marks the entry read on open, toggle reactions from your palette, open original in browser.
- Theming: FeedPug’s Blueprint default plus the shared neiam OKLCh palettes (Her, After Dark, Forest, Sky, Clays, Stones, Dark, Light) and the B612 typeface — the same set as the web frontend. Pick a theme from the palette menu in the timeline top bar; the choice persists in its own encrypted prefs file.
Build
Requires Android Studio (Koala+) or a local Android SDK with JDK 17–21. Point at
your SDK via local.properties (sdk.dir=/path/to/Android/Sdk) or ANDROID_HOME.
cd android
./gradlew :app:assembleDebug # builds app/build/outputs/apk/debug/app-debug.apk
./gradlew installDebug # install on a connected device/emulator
Builds clean against AGP 8.5.2 / Gradle 8.9 (the wrapper is committed). Verified to produce a debug APK with SDK platform 34 + build-tools 34/35/36.
Connecting to a dev server
- The manifest sets
usesCleartextTraffic="true"so you can point at a plainhttp://LAN address during development. - On the Android emulator, the host machine is reachable at
http://10.0.2.2:4000. Generate the web token while serving on that host, or just edit the Server URL field on the Pair screen after scanning. - For real devices, use your machine’s LAN IP (e.g.
http://192.168.1.50:4000) or setFEEDPUG_PUBLIC_URLon the server so the QR encodes a reachable origin.
Layout
app/src/main/java/org/neiam/feedpug/app/
MainActivity.kt # nav host + feedpug://pair deep-link handling
data/PairUri.kt # parse feedpug://pair?base=&token=
data/TokenStore.kt # EncryptedSharedPreferences credential store
data/ThemeStore.kt # persisted theme preference
data/Models.kt # kotlinx.serialization API models
data/FeedPugClient.kt # Retrofit API + Bearer interceptor
ui/PairScreen.kt # QR scan + manual pairing
ui/TimelineScreen.kt # newsfeed list + theme menu
ui/DetailScreen.kt # entry reader + reactions
ui/theme/AppTheme.kt # neiam OKLCh palettes (ALL_THEMES)
ui/theme/Typography.kt # B612 typography
ui/theme/Theme.kt # FeedPugTheme wrapper (palette + type)
res/font/ # b612_regular.ttf, b612_bold.ttf
Release builds
CI builds and signs the release APK in a container
(android/Dockerfile) and publishes it to the forge’s own F-Droid
repository — nothing is installed on the runner, and the same command
reproduces it locally:
docker buildx build --target android-export \
--secret id=keystore,src=./release.jks \
--secret id=keystore_password,src=./keystore.pass \
--progress=plain \
--output type=local,dest=./apk --file android/Dockerfile android
The keystore is a BuildKit secret, so it never reaches a Gradle script, a Gradle cache, or an image layer. Signing is a separate step from Gradle for the same reason.
.forgejo/workflows/android.yml runs that on every push to master
and POSTs the APK to /pkg/fdroid/<owner>/<repo>/upload with the
job’s own token — there is no publishing credential to create.
versionCode comes from the CI run number and versionName from the
commit, so every published build sorts after the last one. Local builds
stay at versionCode = 1 / …+dev; they are never published.
Install from the repository by adding it in F-Droid:
<forge>/pkg/fdroid/<owner>/_all?fingerprint=<fingerprint>
curl -fsSL <forge>/pkg/_keys/fdroid/<owner> # prints the fingerprint
One entry covers every Android app the owner publishes, this one included.
Two different keys, deliberately. The APK key below is what Android checks on every update. The index key is per-owner, lives in the forge’s OpenBao transit engine, and never comes near CI.
The release keystore
Generate it once. Android verifies this certificate on every update, so losing it means every install has to be uninstalled and redone by hand — there is no recovery and no rotation.
keytool -genkeypair -v \
-keystore release.jks \
-storetype PKCS12 \
-alias feedpug \
-keyalg RSA -keysize 4096 \
-validity 10000 \
-dname "CN=FeedPug, O=neiam, C=US"
It prompts for the password rather than taking -storepass, which
keeps it out of shell history. PKCS12 uses one password for the store
and the key, which is what android/Dockerfile assumes — it passes the
one password file to --ks-pass and lets the key password default to
it.
Back it up before it exists anywhere else
So there is never a window where your laptop holds the only copy:
export BAO_TOKEN=<root token>
E() { kubectl -n openbao exec -i statefulset/openbao -- env BAO_TOKEN="$BAO_TOKEN" "$@"; }
E bao kv put kv/feedpug/android \
keystore="$(base64 -w0 release.jks)" \
password='<the password>'
kv-v2 versions writes, so a later overwrite doesn’t destroy this one.
Recover with:
E bao kv get -field=keystore kv/feedpug/android | base64 -d > release.jks
Hand it to CI
On the repository: Settings → Actions → Secrets.
| Secret | Value |
|---|---|
ANDROID_KEYSTORE |
base64 -w0 release.jks |
ANDROID_KEYSTORE_PASS |
the password |
The android job skips cleanly while these are unset, so nothing
breaks before you get to it. It refuses to fall back to a debug-signed
APK: that key is shared by everyone with an SDK, so publishing one
would let anyone ship an “update” to it.
CI does not read the keystore out of OpenBao. apksigner has no
external-signer hook, so the key has to reach the build container
whichever store it comes from, and the runner is outside the cluster
while OpenBao has no ingress on purpose. Bao holds the durable copy;
the Forgejo secret is the operational one.