Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,7 @@ local.properties

/docs/
/docs/

# Python tooling (tools/)
__pycache__/
*.pyc
194 changes: 194 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
# AGENTS.md

This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.

## Build & Development Commands

> [!IMPORTANT]
> **Builds require JDK 17.** The machine default may be newer (e.g. JDK 23), which Gradle 8.6
> rejects ("Unsupported class file major version 67"). Prefix every Gradle command:
> ```bash
> JAVA_HOME=/path/to/jdk-17 ./gradlew <task>
> ```
> Do **not** hardcode `org.gradle.java.home` in the committed `gradle.properties` (Android Studio
> uses its own JDK). A clean full build takes ~6–7 min cold, seconds when warm.

```bash
./gradlew :app:assembleDebug # Debug APK (does NOT compile unit tests)
./gradlew :app:compileDebugKotlin # Fast Kotlin-only check while iterating
./gradlew :app:installDebug # Install to connected device
./gradlew :botok:test # The real unit suite (~198 tokenizer tests)
./gradlew lint

# Backend (api-backend/)
npm run build # Compile TypeScript
npm run deploy # Deploy to Firebase Functions (asia-south1)
```

> [!NOTE]
> `:app:testDebugUnitTest` now compiles and runs (the stale `AIGrammarViewModelTest`, which
> referenced deleted Grammar classes, was removed). It holds **pure JVM tests** for the
> conversion-critical decision logic — `ProStripState` (free-vs-PRO strip), `UnlockRouter`
> (auth→paywall routing), `PostLoginDestination` (post-login routing), and `KeyboardLayoutTest`
> (guards the removed "+"/`-20` key across all `res/xml` layouts). Keep view/IME/RevenueCat-SDK
> behaviour out of these (no mockito/Robolectric); test it by extracting the decision into a pure
> unit (see those classes) or via on-device verification. `:botok:test` remains the tokenizer suite.

## Architecture Overview

**MVVM** for app screens (Jetpack **Compose** UI) + **View-based IME** for the keyboard itself.

```
com.kharagedition.tibetankeyboard/
├── app/ # Application (global night mode, RevenueCat, FCM init)
├── service/ # TibetanKeyboard.kt — the IME InputMethodService
├── ui/
│ ├── compose/
│ │ ├── theme/ # Color, Tokens (gradients/radii), Type, Theme — design system
│ │ └── components/# AppIcons, Components (IconTile, GoldToggle, BackHeader…), ScreenScaffold
│ ├── keyboard/ # TibetanKeyboardView, AIKeyboardView, EmojiKeyboardView, SuggestionStripView
│ ├── home/ # HomeActivity + HomeViewModel + HomeScreen (Compose)
│ ├── settings/ # SettingsActivity + SettingsViewModel + SettingsScreen + SettingsPrefs
│ ├── chat/ # ChatActivity + ChatViewModel + ChatScreen
│ ├── subscription/ # PremiumActivity + PremiumViewModel + PremiumScreen
│ ├── about/ # AboutActivity + AboutScreen
│ ├── login/ # LoginActivity + LoginViewModel + LoginScreen
│ └── splash/ # SplashScreenActivity (Compose)
├── data/
│ ├── repository/ # ChatRepository, UserRepository, RevenueCatManager,
│ │ # subscriptionCallback() helper
│ ├── remote/ # RetrofitClient singleton + API interfaces
│ ├── local/ # UserPreferences (SharedPreferences wrapper)
│ └── model/ # Data classes
├── auth/ # AuthManager (Firebase Auth)
└── util/ # TKExtension (showToast, showConfirmationDialog, …), CommonUtils, AppConstant
```

### UI: Jetpack Compose

All app screens are **Compose** (Splash, Login, Home, Settings, Chat, Premium, About). The only
View-based UI left is the **IME keyboard** (see below) and ad views embedded via `AndroidView`.

**Design system** — `ui/compose/theme` mirrors `docs/tibetan-keyboard/project/tokens.css`
(warm brown/gold "monk-robe" palette):
- `TibetanColors` — the full colour ramp. `TibetanTokens` — gradients, radii, elevation.
- `TibetanKeyboardTheme { … }` wraps every screen's `setContent`. The app is a single
premium **dark-warm** look (forced light mode globally in `Application`).
- Tibetan text uses **Uchen** (block) via the **system font** (`FontFamily.Default` → Noto Sans
Tibetan). Do NOT bundle `jomolhari_regular.ttf` (it's a 0-byte/corrupt file and crashes
Compose's font loader); never use the cursive Ume face.

### MVVM conventions (follow these for new screens)

- **ViewModels own UI state** as `StateFlow<XxxUiState>`; screens collect via
`collectAsStateWithLifecycle()`. Premium status is observed inside the VM from
`RevenueCatManager.isPremiumUser` (LiveData) with `observeForever` + `removeObserver` in
`onCleared()`.
- **Composable screens are stateless**: take a `state` object + an `actions` class of lambdas.
No business logic in composables.
- **Activities hold only framework glue** that genuinely needs an Activity: `InputMethodManager`
queries, `ActivityResultLauncher` (Google sign-in), `RevenueCatManager.purchasePremium(activity)`,
ad `AndroidView`s, intents/navigation. This is correct MVVM — those APIs require an Activity, so
they do **not** belong in a ViewModel.
- Reuse `ScreenScaffold { … }` for scrollable screens (handles full-bleed background + status/nav
bar insets + scroll). `targetSdk 36` forces edge-to-edge, so **always** consume insets or content
draws under the status bar.
- Build `RevenueCatManager.SubscriptionCallback`s with `subscriptionCallback(onSuccess =, onError =)`
rather than hand-written anonymous objects.
- **Strings**: user-facing UI chrome lives in `res/values/strings.xml` via `stringResource(...)`.

### Data Flow

```
Compose screen (collectAsStateWithLifecycle)
↑ StateFlow ↓ actions (lambdas)
ViewModel ──── Repository.apiCall() [Coroutine on IO] ──→ Retrofit → Backend
```

### IME Service Flow (View-based, NOT Compose)

`TibetanKeyboard` (InputMethodService) rebuilds the keyboard on every `onStartInputView`, so
preference changes apply the next time the keyboard opens.
- `AIKeyboardView` — top toolbar (AI grammar/translate/rephrase) + suggestion strip + the keyboard
container. AI features run **inside** this panel (`showAIInterface()`), not as separate screens.
- `TibetanKeyboardView extends KeyboardView` — the typing surface. Custom `onDraw` paints the
**Enter key gold** on top of any layout (visual only; key codes untouched).
- `SuggestionStripView` — Botok autocomplete; top suggestion gold, rest cream.
- `KeyboardMode` enum: `NORMAL`, `AI_GRAMMAR`, `AI_REPHRASE`.

**Keyboard layouts & theming** are driven by `PreferenceManager.getDefaultSharedPreferences`
(the same store the Compose Settings screen writes — see `SettingsPrefs`):
- `keyboard_style`: `modern` (flat), `classic` (raised gradient caps), `borderless` (glyphs only).
Selected in Settings → Keyboard Layout. Brown key drawables: `normal/pressed_{classic,modern}_brown`,
`key_background_borderless`.
- `colors`: brown/black/green; the IME darkens the chosen colour to an espresso surface at runtime
(`darkenColor`, and `AIKeyboardView.applyTheme`) so brown caps pop.
- `vibrate`, `sound`, `event_notification`: behaviour flags read by the IME.
- Keyboard key XML layouts live in `res/xml/` (tibetan_uchen_alphabet_1/2, qwerty, etc.) — **never
change key codes** there; they drive `onKey()`.

### Premium Feature Gating

`RevenueCatManager` (entitlement `"pro"`) is the single source of truth on the client. It is
configured at process start — **anonymously when nobody is signed in** — so a signed-out user can
still be recognised as a subscriber and can buy without logging in first (`UnlockRouter` always
routes to the paywall). Sign-in is asked for afterwards, when a server-backed AI screen needs a UID.

Free-tier limits live in the **backend**, not the client: `FREE_DAILY_CHAT_MESSAGES=100`,
`DAILY_TRANSLATION_CHARS=5000`, and `20`/`2000` for anonymous callers
(`api-backend/src/config/constants.ts`), enforced by `enforceFreeLimit` on every Claude-reaching
route. The old client-side `PremiumFeatureManager` and its "Grammar 10/day, Transliteration 20/day"
counters were dead code with no call sites and have been removed.

Theme/layout entitlement is enforced at *apply* time via `SettingsPrefs.effectiveColor/effectiveStyle`,
not just in the Settings picker — the IME reads the preference store directly, so a lapsed
subscriber must fall back to the free defaults without their saved choice being overwritten.

Products live in the RevenueCat `pro_v2` offering (monthly / annual / lifetime), read by
identifier rather than `offerings.current`. Regional prices are set with `tools/play_pricing.py`;
never price one plan by converting another plan's price from a different currency.

### Backend (`api-backend/`)

Node.js/TypeScript Express on Firebase Functions. AI: **Claude** via `anthropicService`
(`claude-haiku-4-5` / `claude-sonnet-4-6`, user-switchable per request). Translation: **Azure**,
falling back to Claude server-side. Auth middleware: API key + `attachPro` + per-user Firestore
usage limits + a project-wide daily budget.
The app calls `POST /chat` and `POST /translate`. The `/api/grammar/*`, `/api/transliterate/*` and
`/api/chat/message` routes have no client call sites (their screens were removed) but still reach
Claude, so they carry the same middleware chain.

### Key Libraries

| Purpose | Library |
|---------|---------|
| UI | Jetpack Compose (BOM 2024.09.00, Material3) + Compose compiler plugin 2.0.0 |
| Compose↔ViewModel | lifecycle-viewmodel-compose / lifecycle-runtime-compose 2.8.7, runtime-livedata |
| Images | Coil 2.7.0 (Compose) + Glide 4.16.0 (GIF in `AndroidView`) |
| HTTP | Retrofit 2 + OkHttp3 |
| Async | Kotlin Coroutines 1.8.1 |
| Subscriptions | RevenueCat 9.2.0 |
| Ads | AdMob 23.6.0 (`TemplateView` native, `AdView` banner via `AndroidView`) |
| Tokenizer | `:botok` module |

### Build Configuration

- **App ID**: `com.kharagedition.tibetankeyboard` · **Min SDK** 24 (RevenueCatUI) · **Target/Compile SDK** 36
- **Kotlin** 2.0.0 · **Java** 17 · **AGP** 8.3.2 · **Gradle** 8.6
- `buildFeatures { compose = true; viewBinding = true; buildConfig = true }`
- Release: ProGuard + resource shrinking; `app/lint-baseline.xml` suppresses known lint issues.

## Conventions & gotchas (future reference)

- **Reskinning a screen?** Keep the ViewModel/repository; only swap the composable. State stays
hoisted to the VM.
- **New screen?** `XxxActivity` (glue) + `XxxViewModel` (StateFlow state) + `XxxScreen` (stateless
composable in `TibetanKeyboardTheme` + `ScreenScaffold`). Wrap callbacks in an `XxxActions` class.
- **Touching the keyboard?** It "functions exact" — restyle drawables/`onDraw`/colours only; never
alter key codes, `onKey()` routing, or the `res/xml` key definitions.
- **Adding strings?** Check `strings.xml` for an existing key first (duplicate names fail the
resource merge).
- **Always device-verify Compose changes** (`installDebug` + screenshot), not just `assembleDebug` —
a green build can still crash at runtime (e.g. the empty-font bug).
- A few inline strings remain (Premium feature descriptions, quick-action labels, Tibetan
placeholders) — externalize if expanding i18n.
35 changes: 27 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ com.kharagedition.tibetankeyboard/
│ ├── login/ # LoginActivity + LoginViewModel + LoginScreen
│ └── splash/ # SplashScreenActivity (Compose)
├── data/
│ ├── repository/ # ChatRepository, UserRepository, RevenueCatManager, PremiumFeatureManager,
│ ├── repository/ # ChatRepository, UserRepository, RevenueCatManager,
│ │ # subscriptionCallback() helper
│ ├── remote/ # RetrofitClient singleton + API interfaces
│ ├── local/ # UserPreferences (SharedPreferences wrapper)
Expand Down Expand Up @@ -129,15 +129,34 @@ preference changes apply the next time the keyboard opens.

### Premium Feature Gating

`PremiumFeatureManager` + `RevenueCatManager` (entitlement `"pro"`). Free-tier daily limits in
Firestore: Grammar 10/day, Transliteration 20/day, Chat 100 messages.
`RevenueCatManager` (entitlement `"pro"`) is the single source of truth on the client. It is
configured at process start — **anonymously when nobody is signed in** — so a signed-out user can
still be recognised as a subscriber and can buy without logging in first (`UnlockRouter` always
routes to the paywall). Sign-in is asked for afterwards, when a server-backed AI screen needs a UID.

Free-tier limits live in the **backend**, not the client: `FREE_DAILY_CHAT_MESSAGES=100`,
`DAILY_TRANSLATION_CHARS=5000`, and `20`/`2000` for anonymous callers
(`api-backend/src/config/constants.ts`), enforced by `enforceFreeLimit` on every Claude-reaching
route. The old client-side `PremiumFeatureManager` and its "Grammar 10/day, Transliteration 20/day"
counters were dead code with no call sites and have been removed.

Theme/layout entitlement is enforced at *apply* time via `SettingsPrefs.effectiveColor/effectiveStyle`,
not just in the Settings picker — the IME reads the preference store directly, so a lapsed
subscriber must fall back to the free defaults without their saved choice being overwritten.

Products live in the RevenueCat `pro_v2` offering (monthly / annual / lifetime), read by
identifier rather than `offerings.current`. Regional prices are set with `tools/play_pricing.py`;
never price one plan by converting another plan's price from a different currency.

### Backend (`api-backend/`)

Node.js/TypeScript Express on Firebase Functions. AI: Gemini 2.0 Flash. Translation: Google
Translate. Auth middleware: API key + per-user Firestore usage limits.
Endpoints: `POST /chat`, `POST /translate` (grammar/transliterate endpoints exist server-side but
the in-app Grammar/Transliteration screens were removed as dead code).
Node.js/TypeScript Express on Firebase Functions. AI: **Claude** via `anthropicService`
(`claude-haiku-4-5` / `claude-sonnet-4-6`, user-switchable per request). Translation: **Azure**,
falling back to Claude server-side. Auth middleware: API key + `attachPro` + per-user Firestore
usage limits + a project-wide daily budget.
The app calls `POST /chat` and `POST /translate`. The `/api/grammar/*`, `/api/transliterate/*` and
`/api/chat/message` routes have no client call sites (their screens were removed) but still reach
Claude, so they carry the same middleware chain.

### Key Libraries

Expand All @@ -154,7 +173,7 @@ the in-app Grammar/Transliteration screens were removed as dead code).

### Build Configuration

- **App ID**: `com.kharagedition.tibetankeyboard` · **Min SDK** 23 · **Target/Compile SDK** 36
- **App ID**: `com.kharagedition.tibetankeyboard` · **Min SDK** 24 (RevenueCatUI) · **Target/Compile SDK** 36
- **Kotlin** 2.0.0 · **Java** 17 · **AGP** 8.3.2 · **Gradle** 8.6
- `buildFeatures { compose = true; viewBinding = true; buildConfig = true }`
- Release: ProGuard + resource shrinking; `app/lint-baseline.xml` suppresses known lint issues.
Expand Down
5 changes: 4 additions & 1 deletion api-backend/firebase.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,8 @@
"region": "asia-south1",
"runtime": "nodejs22"
}
]
],
"firestore": {
"rules": "firestore.rules"
}
}
51 changes: 51 additions & 0 deletions api-backend/firestore.rules
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
rules_version = '2';

// Replaces an unversioned wide-open ruleset (`allow read: if true` / `allow write: if request.auth
// != null`) under which any signed-in user could set users/{anyone}.isPro and every document was
// world-readable. The backend uses the Admin SDK and bypasses these rules, so anything only it
// touches gets no client rule and is denied.

service cloud.firestore {
match /databases/{database}/documents {

function signedInAs(uid) {
return request.auth != null && request.auth.uid == uid;
}

// Webhook/REST-backfill only: a client that could set these owns the entitlement.
function proFields() {
return ['isPro', 'proExpiresAt', 'proUpdatedAt'].toSet();
}

function keepsProFieldsOut() {
return !request.resource.data.keys().toSet().hasAny(proFields());
}

function leavesProFieldsAlone() {
return !request.resource.data.diff(resource.data).affectedKeys().hasAny(proFields());
}

// Profile, device info and the daily activity ping. Own document only — nothing in the app
// reads another user's record (the Journey community comparison is computed server-side).
match /users/{uid} {
allow get: if signedInAs(uid);
allow list: if false;
allow create: if signedInAs(uid) && keepsProFieldsOut();
allow update: if signedInAs(uid) && leavesProFieldsAlone();
allow delete: if false;
}

// Append-only product analytics. Clients add events and never read them back.
match /user_analytics/{event} {
allow create: if request.auth != null;
allow read, update, delete: if false;
}

// Everything else — anon_usage, system_usage, journey_stats, conversations, messages,
// grammar_history, transliteration_history — is Admin SDK only. Spelled out rather than left
// implicit so the intent survives the next person reading this file.
match /{document=**} {
allow read, write: if false;
}
}
}
2 changes: 2 additions & 0 deletions api-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"shell": "npm run build && firebase functions:shell",
"start": "npm run shell",
"deploy": "firebase deploy --only functions",
"deploy:rules": "firebase deploy --only firestore:rules",
"test:rules": "python3 ../tools/firestore_rules_test.py",
"logs": "firebase functions:log"
},
"engines": {
Expand Down
Loading
Loading