Skip to content

About

Native Android client for Agrent — plan stage. Kotlin/Compose, PKCE auth via Custom Tabs, Keystore token storage. Reuses the iOS backend contract unchanged.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Agrent Android — plan

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.


The short answer on reuse: the backend is already platform-neutral

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


What the server already gives you

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.


Proposed shape

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.time without 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.

First slice: Field Journal

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.


Known traps, paid for already

  1. docker restart does not reload env_file. Recreate the container.
  2. Two controls, not one. "It works now" cannot distinguish enforcing from disabled — check that a disallowed input is still refused.
  3. Read the wire, not the use-case. Both iOS decode bugs came from assuming the response shape.
  4. A guard can pin the wrong thing. Two agri-saas guards asserted routes used getServerSession, 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.

Status

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.

About

Native Android client for Agrent — plan stage. Kotlin/Compose, PKCE auth via Custom Tabs, Keystore token storage. Reuses the iOS backend contract unchanged.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages