A new paid AI recitation review feature for the Android app — record an ayah, spend one credit, and get a word-by-word tajweed grade. This brief explains the whole machine: which API is called first, what the backend does, what database it uses, how credits and Google Play billing work, and the service map from the phone all the way to the server.
The Android side is a thin, careful conductor — it records audio, reuses the login token, and shows results. Every decision that touches money or correctness is made and stored on the server. Hold these three invariants in your head and the whole feature makes sense.
The app never decrements a counter locally. AiCreditsStore only reflects the number
the server returns from /billing/quota or the credits_remaining field on each result.
tok per recordingEach take gets a fresh UUID. It names the audio file, rides along in the request label, and keys
the debit ledger — so a retry replays the same result and can never double-charge.
The AI backend federates the same login token and keys everything to lq_<sub>.
That is how one balance and one history are shared across iOS and Android.
A handful of domain terms recur everywhere below. Skim these once.
incorrect. Scored by score_jaliy.score_khofiy; carries a severity (minor/moderate).label as tok=<uuid>; the eval debit ledger is unique on it.sub claim of the LQ login JWT. Stable across devices; no AI-side account row is created.ref="surah:ayah" so the user can compare their take to a reference.Trace a single AI check through the stack. Each layer has exactly one job, and a clear trust boundary sits between the two backends.
api.learn-quran.co. Mints the login JWT at POST /api/v3/login. Never called for AI — only to get / refresh the token.
aitajweed/. Records audio, attaches the stored JWT as Authorization: Bearer, runs the Play purchase, posts the audio.
ai-tajweed-evaluator. Verifies the JWT itself (HS256), enforces credits, calls the model, writes the DB. The brain.
The scoring LLM, the Google Play Developer API (purchase verify), ffmpeg (decode), and the qari reference CDN.
The service's own MySQL holds credits, ledger, attempts. Audio blobs live on a Railway filesystem volume.
LQ_JWT_SECRET on the AI service must equal
the Laravel JWT_SECRET of lqtajwid_api_mobile). That is what lets the AI service trust a
Learn Quran login without ever seeing the password.There is a strict prerequisite chain. Authentication comes first — but not against the AI backend. The app reuses the login token it already has, then talks to the AI service.
PrefsManager.authToken."token" as PrefsManager.authToken.AiApiClient interceptor adds Authorization: Bearer <that exact token>. No separate AI login, no token exchange.verify_lq_jwt splits the JWT, requires alg=HS256, HMAC-SHA256 constant-time compares the signature with LQ_JWT_SECRET, checks exp. → identity lq_<sub>.{"detail":"invalid or expired token"}.RefreshAuthenticator → GET api/v2/token/refresh once, persists the new token, retries the call. Still 401 → route the user to re-login.LQ_JWT_SECRET (= Laravel JWT_SECRET of lqtajwid_api_mobile) on both
Railway envs. A 2026-06-12 finding showed it was still a dev placeholder, which 401s every real login token and
reproduces the historic iOS "please log in again" symptom for all users. This is config-only (no app release) —
confirm it before shipping to real accounts.The AI service runs its own MySQL (PyMySQL over MYSQL_URL, a Railway MySQL addon
today; an Azure-migration runbook exists). It is a separate ai_tajweed schema with no joins to the
Learn Quran Laravel DB — everything keys on lq_<sub>. The schema is created idempotently
on boot (storage.init_db() → CREATE TABLE IF NOT EXISTS + ALTER + product seed).
MySQL on Railway, the AI service's own schema. The app holds no ledger or DB — it is a stateless caller.
Recordings are written as files to a Railway volume (DATA_DIR/audio/<attempt_id>.ext); only the filename lives in attempts.audio_path.
The only thing shared with the Laravel DB is the JWT secret — never a table, row, or connection.
UPDATE … SET balance = balance-1 WHERE user_id=? AND balance >= 1 — and the matching
ledger row is UNIQUE(user_id, reason, ref). Two devices can't spend the same credit, and a retried
request can't charge twice.
users / sessions / guests tables for its own web account system — the mobile app never touches those; it uses JWT federation, which creates no AI-side account.)Eight steps from the gold mic to a stored result. The controller is
AiRecitationFlow; the headless engine is EvaluationJobManager; the orchestrator is
AiTajweedRepository.
aiFlow.onAiCheckClicked(). In Self mode it records free. In AI mode it first dedups any in-flight job for this item (itemKey = courseId#mode#index).
AiGate.decide(): not logged in → sign-in; zero balance → paywall; otherwise → record. Login is always checked before credits, and an unknown balance never charges — it re-fetches first.
Allocate tok = newTok(), write to cacheDir/AITajweed/<tok>.m4a (mono AAC). The Arabic stays visible behind a floating RecordingPill; auto-stops at the per-item limit.
stopAndConfirm() rejects too-short takes, then shows ConsentSheet with a replay player, the truthful credit cost, and "what is sent". It never auto-submits; dismissing keeps the take.
Ai.jobs.submit(...) creates a persisted EvaluationJob (jobs.json, newest-first), dedups by itemKey, and launches on an app-scoped IO coroutine so it survives navigation.
repo.evaluate() → multipart POST /evaluate_cloud (audio + ref/arabic_text + mode + label … tok=). Bearer attached by LqAuthTokenProvider; up to 5 retries on network/provider/in-progress/rate-limit. ~13–20s; 120s client timeout.
The finished stream fires. If the user is still on the item & foreground → open AiResultActivity. Otherwise a global "See full result" snackbar. A claimed tok guarantees exactly one fires. (Test marks the item correct at score ≥ 70.)
AiReviewsActivity merges GET /history with local pending/failed jobs (offline fallback). Tapping a failed row retries with the same tok → no double charge.
audio.m4a + ref="112:1" + label="… tok=2f1d…" (Bearer LQ JWT)tok.ffmpeg decodes to 16kHz mono → scoring model listens, transcribes, aligns to the verse, scores jaliy/khofiy. (~13–20s.)attempts row with the full result JSON.attempt_id + credits_remaining. On provider 502/503 or disconnect → auto-refund the credit (net cost 0).credits_remaining → render result screen (or snackbar). Done.POST /evaluate_cloudOne synchronous HTTP request returns the entire result — there is no job id and no polling for evaluation. The billing is "pay-first": the credit is debited before the model runs, and refunded if the model fails.
| Field | Required | Notes |
|---|---|---|
audio | yes | mono AAC in .m4a; ≤12MB, 0.1–90s |
ref | one of* | "surah:ayah" → server resolves text + qari audio |
arabic_text | one of* | explicit answer key (wins over ref) |
mode | no | analytics label: practice/test |
label | yes | must end tok=<uuid> (idempotency) |
* at least one of ref / arabic_text must resolve to Arabic text, else 400 (not charged). provider/model are stripped for non-admins.
# validate BEFORE money size/length ok? no -> 400 (never charged) balance >= 1? no -> 402 (never charged) debit 1 credit # atomic UPDATE ... WHERE balance>=1 ledger row UNIQUE(user, reason, tok) call provider # ~13-20s, synchronous ok -> 200 result + credits_remaining 502/503 -> auto-refund (net 0) retry same tok: finished -> replay result (_idempotent_replay) running -> 409 evaluation_in_progress
AiApiClient sets 120s for the
evaluate call (do not time out at 30s). The server rate-limits evaluation to 6/min and 60/day per user
(a 429 abuse brake, not billing logic). Show a "Scoring…" state throughout.tok=<uuid> in labelOne token per recording. Names the file, rides in the label, keys the debit ledger. A retried submission replays the stored result with _idempotent_replay:true and no second charge. A new recording needs a new tok.
googleplay:<token> ledger refOne grant per unique Google Play purchase token. Re-redeeming the same token returns status:"already" with the unchanged balance — so a crash-recovery sweep or a double RTDN can't double-credit.
The response is the model's JSON, rendered verbatim by EvaluationResult
(UNKNOWN-tolerant DTOs). The same object is read back under "result" from GET /history/{id}.
"Solid recitation with a minor madd issue."
| Field | Meaning |
|---|---|
score_overall | 0–100, holistic (drives the ring; ≥70 marks a Test item correct) |
score_jaliy | 0–100, clear errors (letters/makhraj/harakat) |
score_khofiy | 0–100, subtle tajweed rules |
overall_verdict | excellent/good/needs_work/incorrect |
words[] | per-word chips: correct/jaliy/khofiy + note |
lahn_jaliy[] | "What to fix first": word, expected, heard, letter-swap, explanation |
lahn_khofiy[] | "Fine-tuning": rule, observation, severity |
heard_transcription | phonetic of what the model actually heard |
reference_audio | qari MP3 URL (null when no ref) |
attempt_id | history / audio / feedback id |
credits_remaining | server-authoritative balance after the debit |
_model, _provider, token counts,
cost_usd, raw ref/text) is present but must not be shown. The list endpoint
uses key verdict while the eval result uses overall_verdict — a documented field-name trap.This is the new money path. The app runs a real Google Play purchase of credits_3,
then redeems the Play token at POST /billing/redeem_googleplay (the Android mirror of iOS
redeem_storekit). The grant is idempotent and attributed to the LQ account.
| Actor | buy | token | redeem | grant | consume / spend |
|---|---|---|---|---|---|
| User | taps "Buy 3 credits" | records → "Send" | |||
| Google Play | launchBillingFlow | collects money → purchaseToken | |||
| App | AiPlayBilling.purchase() | Outcome.Success(token) | POST /billing/redeem_googleplay | consume(token) → re-buyable | |
| AI service | verify via Play Developer API (purchaseState==0) | +3 credits, idempotent on googleplay:<token> | /evaluate_cloud debits 1 |
credits_3 — a one-time consumable INAPP product. Play Product ID = backend id. Grants
+3 credits; IDR 5,000 / ~$0.28 (Play shows the localized price; the backend owns the credit count).
POST /billing/redeem_googleplay Authorization: Bearer <LQ JWT> { "product_id":"credits_3", "purchase_token":"<Play token>" } -> { "status":"granted"|"already", "balance": 3 }
Credits live on the account (lq_<sub>), not the device or store. Buy on iOS, sign in on
Android → the gate refreshes /billing/quota and the same balance appears. Logout wipes local AI data
so it can't leak to the next account.
| Scenario | What happens |
|---|---|
| Charged on Play, app died before grant | recoverPendingPlayPurchases() finds the still-owned purchase and re-redeems + consumes. Idempotent → never double-charged or left short. |
| Charged, redeem fails transiently | App does not consume (leaves it owned for the sweep) and shows a soft "pending" screen — not a hard network error (AiApiError.PurchasePending). |
| Play returns PENDING (Ask-to-Buy / voucher) | Benign pending state; nothing redeemed yet. Granted later by the recovery sweep / RTDN. |
| User cancels the Play sheet | PurchaseCanceled → no-op, returns to the pack list. |
| Same token submitted twice | Backend returns "already" — exactly one grant per token. |
voidedpurchases); the recovery sweep currently triggers only when the paywall is
reopened (no launch/login auto-sweep), so a PENDING purchase may never be granted; and the grant key is
per-(user, reason, ref) rather than bound to the buyer, a cross-account double-grant risk worth
acknowledging.Base URL is build-time (BuildConfig.AI_BASE_URL): debug → …-dev, release →
ai-tajweed-evaluator-production.up.railway.app. Error envelopes come in two shapes —
{"detail":…} and {"error":…} — parse both.
| Method · Path | Auth | Purpose |
|---|---|---|
GET /health · ?deep=1 | no | Liveness; ?deep=1 adds a DB check + echoes ai_enabled_to_buy_for_lqtajwid |
GET /cloud_status | no | Provider availability (advisory only) |
GET /billing/products | no | Credit packs; Android sends ?store=google |
GET /billing/quota | yes | {balance, unlimited} — the source of truth |
POST /billing/redeem_googleplay | yes | Grant credits from a Play purchase token (idempotent) |
POST /evaluate_cloud ⭐ | yes | The paid, synchronous AI evaluation (multipart) |
GET /history?limit= | yes | My AI Reviews list (newest first; uses key verdict) |
GET /history/{id} | yes | Detail + nested full result |
GET /history/{id}/audio | no | Recording bytes; Range-streamable; unguessable id |
DELETE /history/{id} · /history | yes | Delete one / clear all |
POST /feedback | yes | Thumbs up/down on a review (optional) |
POST /billing/purchase · /{id} | yes | Generic PSP purchase + poll (the non-Play path) |
Do NOT call from mobile:
/auth/* (the AI service's own web accounts), /admin/*, the local-model /evaluate,
and the server-to-server /billing/webhook. Dev-only: /auth/dev_token and
…/simulate_pay (both 404 on prod).
All HTTP cases map to a typed AiApiError. The credit-impact column is the one to remember:
a successful eval costs exactly 1; a failed one costs 0.
| HTTP | Client behavior | Credit |
|---|---|---|
200 | Render result; sync balance from credits_remaining | −1 |
400 | Audio too short/long/undecodable, or no text → fix & re-record | 0 |
401 | Refresh LQ JWT once, retry; else re-login | 0 |
402 | insufficient_credits → open paywall | 0 |
409 | evaluation_in_progress → wait 3–5s, retry same tok | 0 |
413 | Payload too large (>12MB) → trim client-side | 0 |
429 | Rate limited → back off | 0 |
502 / 503 | Provider transient → safe to retry; 503 ai_temporarily_disabled = kill switch is off | 0 (refunded) |
400 invalid_purchase_token instead of a retryable 503 (the token isn't consumed, so the
sweep heals it). The eval dedup key omits mode, so a Practice job in flight can block the matching Test
item. Mic + amplitude ticker stay hot if the app is backgrounded mid-record. These are tracked, not blockers.A self-contained module at app/src/main/java/com/bi/learnquran/aitajweed/ — Compose UI
hosted from the legacy AppCompat screens via ComposeView, with a headless core engine.
Headless engine: networking, auth, jobs, credits, audio, DTOs. No UI.
Compose surfaces + the record→consent→evaluate→result controller.
ScoreRing, RTL WordChips, AudioCompare, palette, Arabic font.
Practice / Test / Settings / Progress mount ComposeViews & forward callbacks.
core/Ai.ktService locator — wires repo, jobs, credits, auth; clearOnLogout(), recoverPendingPurchases()core/AiTajweedRepository.ktOrchestration: evaluate, quota, products, purchase+redeem, history, feedbackcore/EvaluationJobManager.ktIdempotent persisted background jobs (tok), in-flight dedup, finished streamcore/LqAuthTokenProvider.ktReuses the stored LQ JWT as Bearer; refresh on 401 — the "log in again" fixcore/AiApiClient.kt · AiTajweedApi.ktOkHttp (Bearer interceptor + 401 authenticator + 120s) · Retrofit surfacecore/AiPlayBilling.ktGoogle Play BillingClient v9: purchase, consume, recoverycore/AiCreditsStore.ktServer-truth balance cache (never decremented locally)core/AiTajweedConfig.ktBase URL (BuildConfig), endpoint constants, featureEnabledcore/AiEvaluationModels.kt · AiEnums.ktUNKNOWN-tolerant Gson DTOs & enumsui/AiRecitationFlow.ktThe record→gate→consent→evaluate→result controllerui/AiResultActivity.kt · AiReviewsActivity.ktFull result / history detail · My AI Reviewsui/ConsentSheet.kt · GetCreditsPaywallSheet.ktSelf-sizing bottom sheets (consent · paywall)ui/AiTajweedHosting.ktLauncher facade + AiGate.decide() (login → credits)Server-side — AI_ENABLED_TO_BUY_FOR_LQTAJWID (Railway env). The
real kill switch: when off, /evaluate_cloud returns 503 ai_temporarily_disabled
before any charge, and the app degrades to free Self check. Echoed on /health (currently
true).
Compile-time — AiTajweedConfig.featureEnabled
(const = true). Gates the in-app AI surface; no remote rollback (a known limitation; Practice/Test
don't yet honor it).
Uses the appspot service account learnqurantajwid@appspot.gserviceaccount.com (the same one the live
tajwid backend already uses; authorized for both purchases.products.get and edits.*).
Proof: a fake token to /billing/redeem_googleplay returns
400 invalid_purchase_token — it reached Google and Google rejected it. No dev-trust /
simulation flags exist on prod.
versionCode 643 /
versionName 8.7.35 (app/build.gradle:33-34); the PR body still cites the older
634 / 8.7.34. The branch is release-9.0.0 but the user-facing name stayed 8.7.x to match the
artifact already on the internal track — an open decision. Always read the live internal and production
track codes and pick a code strictly greater than the max; bump to 9.0.0 only if that's the intended
public version.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON (appspot key) +
GOOGLE_PLAY_PACKAGE_NAME=com.bi.learnquran. Until (B), the route safely returns 501 and
grants nothing. Sign the sideload APK with the real upload keystore (debug key → Google Sign-In
DEVELOPER_ERROR 10).LQ_JWT_SECRET on both Railway envs = the real Laravel secret (the blocker)credits_3 is Active + Consumable, priced for every enabled regionGOOGLE_PLAY_PACKAGE_NAME=com.bi.learnquran; no dev-trust/sim varsversionCode > max of internal AND production tracks; resolve 8.7.x vs 9.0.0/health echoes the kill switch; flipping it gates eval before any charge400 invalid_purchase_token (never grants)credits_3: charge → verify → +3 → balance updatesEverything in this brief is distilled from these and verified against the live prod backend and the Kotlin source on 2026-06-19.
00-ANDROID-IMPLEMENTATION.md · 01-backend-contract.md (the API source of truth) · 05-android-arch.md
08-PLAY-CONSOLE-credits_3-SKU.md · 09-CREDITS-CROSS-PLATFORM-SYNC.md · 10-HOW-CREDITS-WORK-SIMPLE.md
12-SESSION-FINDINGS-play-verification.md · 13-PARITY-SWEEP · 14-DEEP-HUNT · 15-RELEASE-9.0.0-DEPLOYMENT.html