A native Android client for Agrent, talking to the same app.agrent.bg API
the iOS app uses. No web code is reused and none is touched.
718 lines of Kotlin, 11 files. COMPILES — ./gradlew assembleDebug
produces an 11 MB app-debug.apk on Ubuntu 24.04 with JDK 17. Three fixes were
needed to get there; see the git history. It has not yet been RUN on a device. The wire shapes
were taken from the iOS client's confirmed models rather than re-derived, so
the decode bugs it already paid for are not repeated here. The reasoning is in
docs/backend-contract.md.
Android needs no server change — one environment variable, appended.
/api/auth/native/* implements a standard OAuth 2.0 authorization-code flow
with PKCE. The only platform-specific thing in it is the redirect URI, and that
is validated by prefix against a comma-separated allowlist:
// src/lib/auth/native/auth-codes.ts
export function classifyRedirect(uri: string, allowlist: string[]): RedirectVerdict {
if (allowlist.length === 0) return 'allowlist_unconfigured';
return allowlist.some((prefix) => prefix.length > 0 && uri.startsWith(prefix))
? 'allowed'
: 'not_on_allowlist';
}No scheme restriction, no platform branch, no https-only rule. So:
NATIVE_AUTH_REDIRECT_ALLOWLIST=bg.agrent.app://auth/callback,bg.agrent.android://auth/callback
This is DONE (2026-09-20). The value was appended on the VM, the app
container recreated (not restarted), and both controls run against the live
endpoint:
| redirect | result | |
|---|---|---|
bg.agrent.android://auth/callback |
307 | newly allowed |
bg.agrent.app://auth/callback |
307 | iOS unaffected |
bg.evil.attacker://auth/callback |
400 | still refused — proves enforcing, not disabled |
Deploying that variable has a trap the iOS work already hit: env_file is
read at container CREATION, so docker restart applies nothing. The container
must be recreated. Verify with two controls rather than one — the start
endpoint should go 400 → 307 for an allowed scheme, and an unlisted scheme must
still return 400. A single positive check cannot tell "enforcing" from
"disabled".
| capability | endpoint / mechanism |
|---|---|
| begin sign-in | GET /api/auth/native/start |
| browser completes | /api/auth/native/complete |
| code → tokens | POST /api/auth/native/exchange → { accessToken, refreshToken } |
| adopt a web session | /api/auth/native/adopt |
| refresh | POST /api/auth/token/refresh — rotating, with family revoke |
| call the API | Authorization: Bearer <accessToken> |
PKCE is S256 only — plain is refused, because it offers no proof of
possession. The auth code is stored as a SHA-256 hash, so exchange is one
indexed read.
Bearer reaches roughly 284 of 353 API routes. They funnel through auth()
in src/auth.ts, which tries the cookie session and falls back to
resolveBearerSession(). A route importing next-auth's raw getServerSession
would skip that fallback and 401 the phone; five did, and agri-saas PR #1032
fixed them and added tests/guards/native-bearer-auth-parity.test.ts to stop
it recurring. If a new endpoint 401s only on mobile, that guard names the cause.
Cookie/bearer equivalence is proven in tests/unit/bearer-cookie-parity.test.ts
differentially — identical claims sent both ways and the answers compared to
each other, rather than to a hand-written expectation that "bearer returns
allow" would satisfy even against an implementation allowing everything.
Mirror the iOS slice so the two clients stay comparable, and so a bug in one is findable in the other.
app/src/main/java/bg/agrent/
AgrentApp.kt application + DI wiring
Config.kt base URL, redirect scheme, tenant slug
auth/
Pkce.kt verifier + S256 challenge
AuthClient.kt start -> Custom Tab -> callback -> exchange
TokenStore.kt EncryptedSharedPreferences (Keystore-backed)
api/
ApiClient.kt bearer header, 401 -> refresh -> retry once
Models.kt wire types, decoded from real responses
JournalApi.kt list + create
journal/
JournalListScreen.kt Compose
JournalViewModel.kt
NewEntryScreen.kt
Platform choices and why
- Kotlin + Jetpack Compose, minSdk 26 (Keystore, Custom Tabs are older; 26
keeps
java.timewithout desugaring). - Chrome Custom Tabs, not a WebView. A WebView shares no system cookie jar,
can't use an existing session, and is rejected by Google's own OAuth guidance.
This is the counterpart to iOS's
ASWebAuthenticationSession. - EncryptedSharedPreferences for the refresh token, Keystore-backed. The counterpart to iOS Keychain.
- No third-party auth SDK. The iOS client hand-rolled PKCE in ~40 lines and stayed dependency-free; AppAuth-Android would pull a large dependency for a flow this small. Reconsider only if the flow grows.
Same feature as iOS — auth, list, create — so the two can be diffed against each other and against the server.
The write contract is already confirmed by the iOS work: nested 409 on
conflict, 426 where an upgrade is required, and If-Match for
conditional writes. Do not re-derive it from the use-case; read the wire. The
iOS repo's history records two decode bugs found exactly that way.
docker restartdoes not reloadenv_file. Recreate the container.- Two controls, not one. "It works now" cannot distinguish enforcing from disabled — check that a disallowed input is still refused.
- Read the wire, not the use-case. Both iOS decode bugs came from assuming the response shape.
- A guard can pin the wrong thing. Two
agri-saasguards asserted routes usedgetServerSession, which was enforcing the cookie-only bug that 401s mobile. If an Android-facing guard is added, assert the invariant (the route authenticates through the wrapper), never the helper's name.
| backend | ready — agri-saas main, #1032 merged 2026-09-20 |
| iOS redirect allowlist | set on the VM |
| Android redirect allowlist | done — bg.agrent.android://auth/callback, verified both ways |
| this app | auth + journal slice, builds to an APK |
| on a device | not yet run — this is the next unknown |
Next: adb install -r app/build/outputs/apk/debug/app-debug.apk on a real
phone and drive the sign-in. Everything it talks to is live and verified.