MR #48 · AI Tajweed Evaluation
Pull Request #48 · learn-quran-co/lqtajwid_android_kotlin

AI Tajweed Evaluation,
end to end.

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.

98
files changed
+16,153
lines added
643
versionCode
credits_3
Play SKU
FastAPI service MySQL + volume Google Play Billing LQ JWT federation Jetpack Compose
The mental model

Client is orchestration. Backend is truth.

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.

Invariant 1

Credits are server-truth

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.

Invariant 2

One tok per recording

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

Invariant 3

The LQ account owns the data

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.

💡
Why two backends? Login stays on the existing Learn Quran Laravel API. All AI work goes to a separate FastAPI service with its own MySQL. They never share a database — they only share the JWT signing secret, so the AI service can verify the login token without a second login.
Speak the language first

Glossary for newcomers

A handful of domain terms recur everywhere below. Skim these once.

Lahn Jaliy clear
A clear / major recitation mistake (wrong letter, makhraj, harakat) that changes meaning. Any single one forces the verdict to incorrect. Scored by score_jaliy.
Lahn Khofiy subtle
A subtle / hidden tajweed-rule slip (madd length, ghunnah, ikhfa, qalqalah, tafkhim). Scored by score_khofiy; carries a severity (minor/moderate).
tok
A per-recording UUID idempotency token. Appended to the request label as tok=<uuid>; the eval debit ledger is unique on it.
lq_<sub>
The federated user id the AI backend keys all data on — derived from the sub claim of the LQ login JWT. Stable across devices; no AI-side account row is created.
credits_3
The Google Play product id (a one-time consumable). One purchase grants +3 credits; 1 credit = 1 AI evaluation.
Qari reference
The gold-standard reciter audio (Mishary Alafasy 128k) the server resolves from ref="surah:ayah" so the user can compare their take to a reference.
The service map

From Android to the server — the five layers

Trace a single AI check through the stack. Each layer has exactly one job, and a clear trust boundary sits between the two backends.

1 · Auth

LQ Laravel API

api.learn-quran.co. Mints the login JWT at POST /api/v3/login. Never called for AI — only to get / refresh the token.

2 · Client

Android AI module

aitajweed/. Records audio, attaches the stored JWT as Authorization: Bearer, runs the Play purchase, posts the audio.

3 · AI service

FastAPI on Railway

ai-tajweed-evaluator. Verifies the JWT itself (HS256), enforces credits, calls the model, writes the DB. The brain.

4 · Providers

Model + Play + CDN

The scoring LLM, the Google Play Developer API (purchase verify), ffmpeg (decode), and the qari reference CDN.

5 · Storage

MySQL + volume

The service's own MySQL holds credits, ledger, attempts. Audio blobs live on a Railway filesystem volume.

🔐
The trust boundary. The two backends are separate hosts and separate databases. The only thing crossing the boundary is the shared JWT secret (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.
"Which API do I call first?"

The call order, and the auth handshake

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.

FIRST · prerequisitePOST api/v3/loginLaravel. Already done at app login → token stored in PrefsManager.authToken.
then (no-auth, optional)GET /billing/products?store=googleRender the paywall price & pack from server data.
then (auth)GET /billing/quotaRead the balance to decide: record, or show paywall.
if zero balance (auth)POST /billing/redeem_googleplayAfter a real Play purchase → grant credits.
the main act (auth)POST /evaluate_cloudMultipart audio → the synchronous AI grade.
later (auth)GET /history · /history/{id}My AI Reviews list & detail; audio playback is no-auth.

The authentication handshake (sequence)

App
→ POST api/v3/login (Laravel, at sign-in). Stores the top-level "token" as PrefsManager.authToken.
App
Every protected AI call: AiApiClient interceptor adds Authorization: Bearer <that exact token>. No separate AI login, no token exchange.
AI service
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>.
AI service
On bad/expired token: ← 401 {"detail":"invalid or expired token"}.
App
On 401, RefreshAuthenticator → GET api/v2/token/refresh once, persists the new token, retries the call. Still 401 → route the user to re-login.
The one config gate that breaks everyone if missed. The AI service must hold the real 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 question everyone asks

What database, and what's in it

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

🗄️

Engine

MySQL on Railway, the AI service's own schema. The app holds no ledger or DB — it is a stateless caller.

🎙️

Audio is NOT in MySQL

Recordings are written as files to a Railway volume (DATA_DIR/audio/<attempt_id>.ext); only the filename lives in attempts.audio_path.

🔒

No legacy joins

The only thing shared with the Laravel DB is the JWT secret — never a table, row, or connection.

The six tables the mobile path touches

ai_credit_balances
  • PKuser_id lq_<sub>
  • balance int — remaining checks
  • unlimited bool (reserved)
ai_credit_ledger
  • user_id
  • reason purchase / debit / refund
  • ref tok or googleplay:<token>
  • UNIQUE (user_id, reason, ref)
ai_products
  • PKid credits_3
  • credits, price_idr, price_usd
  • currency_default, store, sort
  • server-driven — edit = no app release
attempts (history)
  • PKid = attempt_id (uuid4)
  • user_id, mode, label
  • ref, text, score_*, verdict
  • summary, duration_sec, created_at
  • audio_path → volume file
  • result full eval JSON
purchases
  • PKpurchase_id (uuid4)
  • product_id, credits snapshotted
  • amount_idr, amount_usd
  • status pending → paid
  • created_at, paid_at
feedback
  • attempt_id, target_kind
  • target_index, target_key
  • rating 1 / -1 / 0
  • UNIQUE user+attempt+kind+index
⚙️
The atomic debit is what makes credits safe. Spending a credit is a single conditional update — 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.
(The AI service also has 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.)
What happens when the user taps "AI Check"

The end-to-end runtime flow

Eight steps from the gold mic to a stored result. The controller is AiRecitationFlow; the headless engine is EvaluationJobManager; the orchestrator is AiTajweedRepository.

Tap the gold "AI Check" mic

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

ui/AiRecitationFlow.kt · PracticeActivity / TestType2Activity

Gate — login first, then credits

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.

ui/AiTajweedHosting.kt · core/AiCreditsStore.kt

Record with the floating pill

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.

core/AiAudioRecorder.kt · ui/components/RecordingPill.kt

Stop → consent sheet (AI mode)

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.

ui/ConsentSheet.kt · core/AiAudioPlayer.kt

Send → background job

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.

core/EvaluationJobManager.kt (submit, launchEval)

Evaluate over HTTP — synchronous, retried

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.

core/AiTajweedRepository.kt · AiApiClient.kt · AiTajweedApi.kt

Result routing on completion

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

ui/AiResultActivity.kt · ui/AiAppHooks.kt

History — My AI Reviews

AiReviewsActivity merges GET /history with local pending/failed jobs (offline fallback). Tapping a failed row retries with the same tok → no double charge.

ui/AiReviewsActivity.kt · ui/AiReviewRecitationLauncher.kt

Same flow as a cross-actor sequence (with the failure branch)

User
Taps mic → records ayah → taps "Send for AI check" on the consent sheet.
App
→ POST /evaluate_cloud  audio.m4a + ref="112:1" + label="… tok=2f1d…"  (Bearer LQ JWT)
AI svc
Validate size/length (≤12MB, 0.1–90s) → debit 1 credit (atomic, before the model) → write ledger row keyed on tok.
Provider
ffmpeg decodes to 16kHz mono → scoring model listens, transcribes, aligns to the verse, scores jaliy/khofiy. (~13–20s.)
Storage
Save audio to the volume, insert the attempts row with the full result JSON.
AI svc
← 200 full result + attempt_id + credits_remaining.  On provider 502/503 or disconnect → auto-refund the credit (net cost 0).
App
Sync balance from credits_remaining → render result screen (or snackbar). Done.
The core call

Inside POST /evaluate_cloud

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

Request (multipart/form-data)

FieldRequiredNotes
audioyesmono AAC in .m4a; ≤12MB, 0.1–90s
refone of*"surah:ayah" → server resolves text + qari audio
arabic_textone of*explicit answer key (wins over ref)
modenoanalytics label: practice/test
labelyesmust 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.

The pay-first lifecycle

# 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
⏱️
Timeout & rate limits. Use a generous client timeout — 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.

The idempotency contract — two keys, two scopes

Evaluation

tok=<uuid> in label

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

Purchase

googleplay:<token> ledger ref

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

What the user gets back

Anatomy of an AI result

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

good

"Solid recitation with a minor madd issue."

Words & Letters · Lahn Jaliy95
Tajweed Rules · Lahn Khofiy78
Your recitation
قُلْ هُوَ اللَّهُ أَحَدٌ
▶ your take · ♪ reference (Alafasy) · heard: "qul huwa llāhu ahad"
FieldMeaning
score_overall0–100, holistic (drives the ring; ≥70 marks a Test item correct)
score_jaliy0–100, clear errors (letters/makhraj/harakat)
score_khofiy0–100, subtle tajweed rules
overall_verdictexcellent/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_transcriptionphonetic of what the model actually heard
reference_audioqari MP3 URL (null when no ref)
attempt_idhistory / audio / feedback id
credits_remainingserver-authoritative balance after the debit
🙈
Never display the model. Backend meta (_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.
Google Play charges; the backend grants

Billing & credits — the headline of MR #48

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.

Actorbuytokenredeemgrantconsume / spend
User taps "Buy 3 credits"records → "Send"
Google Play launchBillingFlowcollects money → purchaseToken
App AiPlayBilling.purchase()Outcome.Success(token)POST /billing/redeem_googleplayconsume(token) → re-buyable
AI service verify via Play Developer API (purchaseState==0)+3 credits, idempotent on googleplay:<token>/evaluate_cloud debits 1

The SKU

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

Redeem call

POST /billing/redeem_googleplay
Authorization: Bearer <LQ JWT>
{ "product_id":"credits_3",
  "purchase_token":"<Play token>" }
-> { "status":"granted"|"already",
     "balance": 3 }

Cross-platform sync

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.

Failure & recovery — the safety net

ScenarioWhat happens
Charged on Play, app died before grantrecoverPendingPlayPurchases() finds the still-owned purchase and re-redeems + consumes. Idempotent → never double-charged or left short.
Charged, redeem fails transientlyApp 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 sheetPurchaseCanceled → no-op, returns to the pack list.
Same token submitted twiceBackend returns "already" — exactly one grant per token.
📌
Open billing backlog before high-volume prod (deep-hunt P0/P1): no Google refund / voided-purchase clawback yet (RTDN/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.
The contract

Endpoint reference

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 · PathAuthPurpose
GET /health · ?deep=1noLiveness; ?deep=1 adds a DB check + echoes ai_enabled_to_buy_for_lqtajwid
GET /cloud_statusnoProvider availability (advisory only)
GET /billing/productsnoCredit packs; Android sends ?store=google
GET /billing/quotayes{balance, unlimited} — the source of truth
POST /billing/redeem_googleplayyesGrant credits from a Play purchase token (idempotent)
POST /evaluate_cloudyesThe paid, synchronous AI evaluation (multipart)
GET /history?limit=yesMy AI Reviews list (newest first; uses key verdict)
GET /history/{id}yesDetail + nested full result
GET /history/{id}/audionoRecording bytes; Range-streamable; unguessable id
DELETE /history/{id} · /historyyesDelete one / clear all
POST /feedbackyesThumbs up/down on a review (optional)
POST /billing/purchase · /{id}yesGeneric 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).

When things go wrong

Error & recovery matrix

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.

HTTPClient behaviorCredit
200Render result; sync balance from credits_remaining−1
400Audio too short/long/undecodable, or no text → fix & re-record0
401Refresh LQ JWT once, retry; else re-login0
402insufficient_credits → open paywall0
409evaluation_in_progress → wait 3–5s, retry same tok0
413Payload too large (>12MB) → trim client-side0
429Rate limited → back off0
502 / 503Provider transient → safe to retry; 503 ai_temporarily_disabled = kill switch is off0 (refunded)
🐞
Known sharp edges (from the parity / deep-hunt sweeps). A transient Google-verify failure is currently mis-mapped to 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.
Where to start reading

Code map

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.

core/

Headless engine: networking, auth, jobs, credits, audio, DTOs. No UI.

ui/

Compose surfaces + the record→consent→evaluate→result controller.

ui/components · theme/

ScoreRing, RTL WordChips, AudioCompare, palette, Arabic font.

host screens

Practice / Test / Settings / Progress mount ComposeViews & forward callbacks.

Key files

core/Ai.ktService locator — wires repo, jobs, credits, auth; clearOnLogout(), recoverPendingPurchases()
core/AiTajweedRepository.ktOrchestration: evaluate, quota, products, purchase+redeem, history, feedback
core/EvaluationJobManager.ktIdempotent persisted background jobs (tok), in-flight dedup, finished stream
core/LqAuthTokenProvider.ktReuses the stored LQ JWT as Bearer; refresh on 401 — the "log in again" fix
core/AiApiClient.kt · AiTajweedApi.ktOkHttp (Bearer interceptor + 401 authenticator + 120s) · Retrofit surface
core/AiPlayBilling.ktGoogle Play BillingClient v9: purchase, consume, recovery
core/AiCreditsStore.ktServer-truth balance cache (never decremented locally)
core/AiTajweedConfig.ktBase URL (BuildConfig), endpoint constants, featureEnabled
core/AiEvaluationModels.kt · AiEnums.ktUNKNOWN-tolerant Gson DTOs & enums
ui/AiRecitationFlow.ktThe record→gate→consent→evaluate→result controller
ui/AiResultActivity.kt · AiReviewsActivity.ktFull result / history detail · My AI Reviews
ui/ConsentSheet.kt · GetCreditsPaywallSheet.ktSelf-sizing bottom sheets (consent · paywall)
ui/AiTajweedHosting.ktLauncher facade + AiGate.decide() (login → credits)
Shipping it

Release, feature flags & Play verification

Two feature flags

Server-sideAI_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-timeAiTajweedConfig.featureEnabled (const = true). Gates the in-app AI surface; no remote rollback (a known limitation; Practice/Test don't yet honor it).

Play verification is real on prod

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.

🏷️
Version — verify before promotion. The source currently reads 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.
🚦
Two owner-only prod gates for real-money grants: (A) confirm the redeem-capable backend build is deployed to the prod Railway service; (B) set 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).
Before you sign off

Review & test checklists

Review checklist

  • Confirm LQ_JWT_SECRET on both Railway envs = the real Laravel secret (the blocker)
  • credits_3 is Active + Consumable, priced for every enabled region
  • Prod env: appspot SA JSON + GOOGLE_PLAY_PACKAGE_NAME=com.bi.learnquran; no dev-trust/sim vars
  • versionCode > max of internal AND production tracks; resolve 8.7.x vs 9.0.0
  • Sideload APK signed with the real upload keystore (SHA-1 A4:53:89:…)
  • /health echoes the kill switch; flipping it gates eval before any charge
  • Acknowledge the open billing backlog (RTDN clawback, launch/login sweep, token→buyer binding)

Test checklist

  • Fake token → 400 invalid_purchase_token (never grants)
  • Install via a Play track (not sideload — sideload can't run Play Billing); add license testers
  • License-test buy credits_3: charge → verify → +3 → balance updates
  • Buy 3× → 3→6→9; re-send a used token → no-op (idempotent)
  • Cross-platform: buy on iOS, sign in on Android → same balance, no migration
  • Record → Send → word-by-word grade renders; recording is free, only Send debits
  • Kill switch off → eval 503 with no credit charged; app falls back to Self check
  • PENDING purchase shows the soft "pending approval" screen, not a hard error
Go deeper

Source docs in this MR

Everything in this brief is distilled from these and verified against the live prod backend and the Kotlin source on 2026-06-19.

Contracts & architecture

00-ANDROID-IMPLEMENTATION.md · 01-backend-contract.md (the API source of truth) · 05-android-arch.md

Billing & credits

08-PLAY-CONSOLE-credits_3-SKU.md · 09-CREDITS-CROSS-PLATFORM-SYNC.md · 10-HOW-CREDITS-WORK-SIMPLE.md

Quality & release

12-SESSION-FINDINGS-play-verification.md · 13-PARITY-SWEEP · 14-DEEP-HUNT · 15-RELEASE-9.0.0-DEPLOYMENT.html