diff --git a/.gitignore b/.gitignore index 1b2de80..7ff8b46 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,7 @@ local.properties /docs/ /docs/ + +# Python tooling (tools/) +__pycache__/ +*.pyc diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8259f26 --- /dev/null +++ b/AGENTS.md @@ -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 +> ``` +> 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`; 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. diff --git a/CLAUDE.md b/CLAUDE.md index 9107741..7bf75cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) @@ -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 @@ -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. diff --git a/api-backend/firebase.json b/api-backend/firebase.json index c4fd018..609a15f 100644 --- a/api-backend/firebase.json +++ b/api-backend/firebase.json @@ -17,5 +17,8 @@ "region": "asia-south1", "runtime": "nodejs22" } - ] + ], + "firestore": { + "rules": "firestore.rules" + } } diff --git a/api-backend/firestore.rules b/api-backend/firestore.rules new file mode 100644 index 0000000..9afeb4f --- /dev/null +++ b/api-backend/firestore.rules @@ -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; + } + } +} diff --git a/api-backend/package.json b/api-backend/package.json index 18c4fa8..4cb3510 100644 --- a/api-backend/package.json +++ b/api-backend/package.json @@ -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": { diff --git a/api-backend/src/index.ts b/api-backend/src/index.ts index 96dcb0f..6b1de30 100644 --- a/api-backend/src/index.ts +++ b/api-backend/src/index.ts @@ -66,6 +66,13 @@ const azureTranslatorKey = defineSecret("AZURE_TRANSLATOR_KEY"); const revenueCatWebhookSecret = defineSecret("REVENUECAT_WEBHOOK_SECRET"); const revenueCatApiKey = defineSecret("REVENUECAT_API_KEY"); +/** A safe Firestore doc id, or null. `.doc("")` throws, turning a missing header into a 500. */ +function firestoreUserId(raw: unknown): string | null { + const id = typeof raw === "string" ? raw.trim() : ""; + if (!id || id === "anonymous" || id.includes("/")) return null; + return id; +} + // Reusable middleware that sets req.isPro (Firestore mirror + RC REST fallback). const attachPro = attachProStatus(() => { try { @@ -342,108 +349,129 @@ app.post("/chat/reset", async (req, res) => { // ============================================ // Advanced Tibetan Grammar Analysis Endpoint -app.post("/api/grammar/analyze", aiLimiter, async (req, res) => { - try { - const { text, mode = "realtime", style = "formal", contextualInfo } = req.body; - const userId = req.headers["userid"] as string; +// Metered like /chat — reaches Claude, so it needs the pro check, free-tier cap and budget. +app.post( + "/api/grammar/analyze", + aiLimiter, + aiDailyLimiter, + attachPro, + enforceFreeLimit("chat"), + enforceGlobalDailyBudget, + async (req, res) => { + try { + const { text, mode = "realtime", style = "formal", contextualInfo } = req.body; + const userId = req.headers["userid"] as string; - if (!text || text.trim().length === 0) { - return res.status(400).json({ - success: false, - error: "Text is required", - }); - } + if (!text || text.trim().length === 0) { + return res.status(400).json({ + success: false, + error: "Text is required", + }); + } - const userLevel = contextualInfo?.userLevel || "intermediate"; - const documentType = contextualInfo?.documentType || "casual"; + const userLevel = contextualInfo?.userLevel || "intermediate"; + const documentType = contextualInfo?.documentType || "casual"; - const result = await advancedGrammarService.analyzeTibetanGrammar( - text, - userLevel, - documentType - ); + const result = await advancedGrammarService.analyzeTibetanGrammar( + text, + userLevel, + documentType + ); - // Save to Firestore history - try { - const db = admin.firestore(); - const userRef = db.collection("users").doc(userId); - const historyRef = userRef.collection("grammar_history").doc(); + // Save to Firestore history. Skipped without a usable id — `.doc("")` throws. + const historyUserId = firestoreUserId(userId); + if (historyUserId) { + try { + const db = admin.firestore(); + const userRef = db.collection("users").doc(historyUserId); + const historyRef = userRef.collection("grammar_history").doc(); + + await historyRef.set({ + text, + corrections: result.corrections, + timestamp: admin.firestore.FieldValue.serverTimestamp(), + documentType, + savedByUser: false, + mode, + }); + } catch (dbError) { + console.error("Error saving to Firestore:", dbError); + // Don't fail the request if Firestore fails + } + } - await historyRef.set({ - text, - corrections: result.corrections, - timestamp: admin.firestore.FieldValue.serverTimestamp(), - documentType, - savedByUser: false, - mode, + return res.json({ + success: true, + data: result, + usage: { + charactersUsed: text.length, + }, + }); + } catch (error) { + console.error("Grammar analysis error:", error); + return res.status(500).json({ + success: false, + error: "Grammar analysis failed", + message: error instanceof Error ? error.message : "Unknown error", }); - } catch (dbError) { - console.error("Error saving to Firestore:", dbError); - // Don't fail the request if Firestore fails } - - return res.json({ - success: true, - data: result, - usage: { - charactersUsed: text.length, - }, - }); - } catch (error) { - console.error("Grammar analysis error:", error); - return res.status(500).json({ - success: false, - error: "Grammar analysis failed", - message: error instanceof Error ? error.message : "Unknown error", - }); } -}); +); // Tone Alternatives Endpoint -app.post("/api/grammar/suggestions", aiLimiter, async (req, res) => { - try { - const { text, correctionId, type } = req.body; - - if (!text) { - return res.status(400).json({ - success: false, - error: "Text is required", - }); - } +// Metered like /chat — see /api/grammar/analyze above. +app.post( + "/api/grammar/suggestions", + aiLimiter, + aiDailyLimiter, + attachPro, + enforceFreeLimit("chat"), + enforceGlobalDailyBudget, + async (req, res) => { + try { + const { text, correctionId, type } = req.body; - let result: any = { - alternatives: [], - examples: [], - culturalNotes: "", - }; - - if (type === "alternatives") { - const tones: Array<"formal" | "casual" | "poetic" | "religious" | "modern"> = [ - "formal", - "casual", - "poetic", - ]; - for (const tone of tones) { - const alternatives = await advancedGrammarService.getToneAlternatives(text, tone); - result.alternatives.push({ - tone, - suggestions: alternatives, + if (!text) { + return res.status(400).json({ + success: false, + error: "Text is required", }); } - } - return res.json({ - success: true, - data: result, - }); - } catch (error) { - console.error("Grammar suggestions error:", error); - return res.status(500).json({ - success: false, - error: "Failed to get suggestions", - }); + let result: any = { + alternatives: [], + examples: [], + culturalNotes: "", + }; + + if (type === "alternatives") { + const tones: Array<"formal" | "casual" | "poetic" | "religious" | "modern"> = [ + "formal", + "casual", + "poetic", + ]; + for (const tone of tones) { + const alternatives = await advancedGrammarService.getToneAlternatives(text, tone); + result.alternatives.push({ + tone, + suggestions: alternatives, + }); + } + } + + return res.json({ + success: true, + data: result, + }); + } catch (error) { + console.error("Grammar suggestions error:", error); + return res.status(500).json({ + success: false, + error: "Failed to get suggestions", + }); + } } -}); +); // Transliteration Endpoints app.post("/api/transliterate/convert", async (req, res) => { @@ -476,22 +504,25 @@ app.post("/api/transliterate/convert", async (req, res) => { }; } - // Save to Firestore history - try { - const db = admin.firestore(); - const userRef = db.collection("users").doc(userId); - const historyRef = userRef.collection("transliteration_history").doc(); - - await historyRef.set({ - sourceText: text, - sourceSystem, - targetSystem, - result: result.result, - timestamp: admin.firestore.FieldValue.serverTimestamp(), - savedByUser: false, - }); - } catch (dbError) { - console.error("Error saving transliteration to Firestore:", dbError); + // Save to Firestore history. Skipped without a usable id — `.doc("")` throws. + const historyUserId = firestoreUserId(userId); + if (historyUserId) { + try { + const db = admin.firestore(); + const userRef = db.collection("users").doc(historyUserId); + const historyRef = userRef.collection("transliteration_history").doc(); + + await historyRef.set({ + sourceText: text, + sourceSystem, + targetSystem, + result: result.result, + timestamp: admin.firestore.FieldValue.serverTimestamp(), + savedByUser: false, + }); + } catch (dbError) { + console.error("Error saving transliteration to Firestore:", dbError); + } } return res.json({ @@ -538,111 +569,120 @@ app.post("/api/transliterate/database/lookup", async (req, res) => { }); // Enhanced Chat with Tutoring Support -app.post("/api/chat/message", aiLimiter, async (req, res) => { - try { - const { - sessionId, - message, - conversationMode = "general", - tutoringLevel = "intermediate", - documentContext, - includeExplanation = false, - } = req.body; - const userId = req.headers["userid"] as string; +// Metered like /chat — see /api/grammar/analyze above. +app.post( + "/api/chat/message", + aiLimiter, + aiDailyLimiter, + attachPro, + enforceFreeLimit("chat"), + enforceGlobalDailyBudget, + async (req, res) => { + try { + const { + sessionId, + message, + conversationMode = "general", + tutoringLevel = "intermediate", + documentContext, + includeExplanation = false, + } = req.body; + const userId = req.headers["userid"] as string; - if (!message) { - return res.status(400).json({ - success: false, - error: "Message is required", - }); - } + if (!message) { + return res.status(400).json({ + success: false, + error: "Message is required", + }); + } - const currentSessionId = sessionId || userId; + const currentSessionId = sessionId || userId; - // Create or update session with mode - const chat = await ChatSessionManager.getOrCreateSession( - currentSessionId, - conversationMode as any, - tutoringLevel as any, - documentContext - ); + // Create or update session with mode + const chat = await ChatSessionManager.getOrCreateSession( + currentSessionId, + conversationMode as any, + tutoringLevel as any, + documentContext + ); - // Send message to Claude - const model = resolveModel( - (req.body.model as string) || (req.headers["x-model"] as string) - ); - const tibetanResponse = await ChatSessionManager.sendMessage( - currentSessionId, - message, - { model, mode: conversationMode as any } - ); + // Send message to Claude + const model = resolveModel( + (req.body.model as string) || (req.headers["x-model"] as string) + ); + const tibetanResponse = await ChatSessionManager.sendMessage( + currentSessionId, + message, + { model, mode: conversationMode as any } + ); - // Save message to Firestore - try { - const db = admin.firestore(); - const conversationRef = db - .collection("users") - .doc(userId) - .collection("conversations") - .doc(currentSessionId); - - // Create or update conversation document - await conversationRef.set( - { + // Save message to Firestore + try { + const db = admin.firestore(); + const conversationRef = db + .collection("users") + .doc(userId) + .collection("conversations") + .doc(currentSessionId); + + // Create or update conversation document + await conversationRef.set( + { + mode: conversationMode, + tutoringLevel: conversationMode === "tutoring" ? tutoringLevel : null, + updatedAt: admin.firestore.FieldValue.serverTimestamp(), + messageCount: admin.firestore.FieldValue.increment(1), + preview: message.substring(0, 100), + lastMessageTime: admin.firestore.FieldValue.serverTimestamp(), + }, + { merge: true } + ); + + // Add message to subcollection + const messagesRef = conversationRef.collection("messages").doc(); + await messagesRef.set({ + sender: "user", + content: message, + timestamp: admin.firestore.FieldValue.serverTimestamp(), mode: conversationMode, - tutoringLevel: conversationMode === "tutoring" ? tutoringLevel : null, - updatedAt: admin.firestore.FieldValue.serverTimestamp(), - messageCount: admin.firestore.FieldValue.increment(1), - preview: message.substring(0, 100), - lastMessageTime: admin.firestore.FieldValue.serverTimestamp(), - }, - { merge: true } - ); + }); - // Add message to subcollection - const messagesRef = conversationRef.collection("messages").doc(); - await messagesRef.set({ - sender: "user", - content: message, - timestamp: admin.firestore.FieldValue.serverTimestamp(), - mode: conversationMode, - }); + // Add response + const responseRef = conversationRef.collection("messages").doc(); + await responseRef.set({ + sender: "assistant", + content: tibetanResponse, + timestamp: admin.firestore.FieldValue.serverTimestamp(), + mode: conversationMode, + confidence: 0.92, + }); + } catch (dbError) { + console.error("Error saving chat to Firestore:", dbError); + } + + const response: GeminiChatResponse = { + success: true, + data: { + response: tibetanResponse, + sessionId: currentSessionId, + messageId: `msg_${Date.now()}`, + }, + usage: { + charactersUsed: message.length + tibetanResponse.length, + }, + }; - // Add response - const responseRef = conversationRef.collection("messages").doc(); - await responseRef.set({ - sender: "assistant", - content: tibetanResponse, - timestamp: admin.firestore.FieldValue.serverTimestamp(), - mode: conversationMode, - confidence: 0.92, + return res.json(response); + } catch (error) { + console.error("Enhanced chat error:", error); + return res.status(500).json({ + success: false, + error: "Chat failed", + message: error instanceof Error ? error.message : "Unknown error", }); - } catch (dbError) { - console.error("Error saving chat to Firestore:", dbError); } - - const response: GeminiChatResponse = { - success: true, - data: { - response: tibetanResponse, - sessionId: currentSessionId, - messageId: `msg_${Date.now()}`, - }, - usage: { - charactersUsed: message.length + tibetanResponse.length, - }, - }; - - return res.json(response); - } catch (error) { - console.error("Enhanced chat error:", error); - return res.status(500).json({ - success: false, - error: "Chat failed", - message: error instanceof Error ? error.message : "Unknown error", - }); } -}); +); // Chat History Endpoint app.get("/api/chat/history", async (req, res) => { @@ -651,8 +691,14 @@ app.get("/api/chat/history", async (req, res) => { const limit = parseInt(req.query.limit as string) || 20; const offset = parseInt(req.query.offset as string) || 0; + const historyUserId = firestoreUserId(userId); + if (!historyUserId) { + res.status(400).json({ error: "Missing or invalid userid header" }); + return; + } + const db = admin.firestore(); - const conversationsRef = db.collection("users").doc(userId).collection("conversations"); + const conversationsRef = db.collection("users").doc(historyUserId).collection("conversations"); let query: any = conversationsRef.orderBy("updatedAt", "desc").limit(limit); diff --git a/app/build.gradle b/app/build.gradle index 707406d..e953f82 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -22,15 +22,20 @@ android { } defaultConfig { applicationId "com.kharagedition.tibetankeyboard" - minSdkVersion 23 + // 24, not 23: RevenueCatUI (Customer Center) requires it. Play reports 3 of 11,154 + // installed devices on API 23 (0.03%). + minSdkVersion 24 targetSdkVersion 36 - versionCode project.hasProperty('versionCode') ? project.property('versionCode').toInteger() : 54 + versionCode project.hasProperty('versionCode') ? project.property('versionCode').toInteger() : 55 - versionName "2.2.11" + versionName "2.2.12" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" multiDexEnabled true vectorDrawables.useSupportLibrary = true + // Overridable from CI (-PrevenuecatApiKey=...) so a sandbox project can be swapped in. + buildConfigField "String", "REVENUECAT_API_KEY", + "\"${project.findProperty('revenuecatApiKey') ?: 'goog_HqifnUJxdgpKcyrUFhRfJfAYIap'}\"" } lint { baseline = file("lint-baseline.xml") @@ -89,11 +94,9 @@ dependencies { implementation 'com.google.firebase:firebase-auth-ktx' implementation 'com.google.firebase:firebase-firestore-ktx' implementation 'com.google.android.gms:play-services-auth:20.7.0' - implementation 'com.airbnb.android:lottie:6.2.0' implementation 'com.github.bumptech.glide:glide:4.16.0' implementation 'com.google.firebase:firebase-analytics:22.2.0' implementation 'com.google.firebase:firebase-messaging-ktx:24.1.0' - implementation 'com.github.bumptech.glide:glide:4.15.1' annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0' implementation 'androidx.work:work-runtime:2.10.0' @@ -108,6 +111,8 @@ dependencies { implementation 'com.google.android.play:app-update-ktx:2.1.0' implementation 'com.revenuecat.purchases:purchases:9.2.0' + // RevenueCatUI — Customer Center. Must track the same version as `purchases`. + implementation 'com.revenuecat.purchases:purchases-ui:9.2.0' implementation project(':botok') // Jetpack Compose diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index db721fc..46fab0e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -43,6 +43,11 @@ android:exported="true" android:label="@string/title_activity_settings" android:parentActivityName=".ui.home.HomeActivity" /> + + Unit)? = null) { - if (isPremiumFeatureAvailable(feature)) { - return - } - - val remaining = getFeatureRemaining(feature) - val message = if (remaining > 0) { - "You have $remaining uses remaining today.\n\nUpgrade to Premium for unlimited access!" - } else { - "Daily limit exceeded.\n\nUpgrade to Premium for unlimited access!" - } - - showPremiumUpsellDialog(activity, feature.featureKey, message, onPurchase) - } - - private fun showPremiumUpsellDialog( - activity: Activity, - featureName: String, - message: String, - onPurchase: (() -> Unit)? = null - ) { - val builder = com.google.android.material.dialog.MaterialAlertDialogBuilder(activity) - .setTitle("Premium Feature") - .setMessage(message) - .setIcon(android.R.drawable.ic_dialog_info) - .setPositiveButton("Upgrade") { _, _ -> - revenueCatManager.purchasePremium(activity, object : com.kharagedition.tibetankeyboard.data.repository.RevenueCatManager.SubscriptionCallback { - override fun onSuccess(message: String) { - onPurchase?.invoke() - } - - override fun onError(error: String) { - // Handle error silently - } - - override fun onUserCancelled() { - // User cancelled the purchase - } - }) - } - .setNegativeButton("Cancel") { dialog, _ -> - dialog.dismiss() - } - .setCancelable(true) - - builder.show() - } - - /** - * Get today's date as a string (API level 23 compatible) - */ - private fun getTodayDateString(): String { - val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) - return dateFormat.format(Date()) - } - - companion object { - private lateinit var instance: PremiumFeatureManager - - fun initialize(context: Context) { - instance = PremiumFeatureManager(context) - } - - fun getInstance(context: Context? = null): PremiumFeatureManager { - if (!::instance.isInitialized && context != null) { - initialize(context) - } - return instance - } - } - - private val revenueCatManager: RevenueCatManager - get() = RevenueCatManager.getInstance() -} diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/RevenueCatManager.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/RevenueCatManager.kt index 96b2709..26826ba 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/RevenueCatManager.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/RevenueCatManager.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import com.google.firebase.auth.FirebaseAuth import com.kharagedition.tibetankeyboard.BuildConfig +import com.kharagedition.tibetankeyboard.analytics.AppAnalytics import com.revenuecat.purchases.* import com.revenuecat.purchases.interfaces.* import com.revenuecat.purchases.models.StoreTransaction @@ -24,6 +25,15 @@ class RevenueCatManager private constructor() { private const val PREMIUM_ENTITLEMENT_ID = "pro" private const val TAG = "RevenueCatManager" + /** Read by identifier, not `offerings.current`, so shipped clients keep the `sale` offering. */ + private const val OFFERING_ID = "pro_v2" + + // Stable codes for failures RevenueCat does not raise as a PurchasesErrorCode. + const val ERROR_NOT_CONFIGURED = "SDK_NOT_CONFIGURED" + const val ERROR_NO_OFFERING = "OFFERING_UNAVAILABLE" + const val ERROR_NO_PACKAGE = "PACKAGE_UNAVAILABLE" + const val ERROR_UNKNOWN = "UNKNOWN" + fun getInstance(): RevenueCatManager { return INSTANCE ?: synchronized(this) { INSTANCE ?: RevenueCatManager().also { INSTANCE = it } @@ -31,9 +41,12 @@ class RevenueCatManager private constructor() { } } + /** Mirrors [Purchases.isConfigured]. */ private var isConfigured = false + get() = field || Purchases.isConfigured - private val _isPremiumUser = MutableLiveData() + // Seeded false so observers never see null before the first callback. + private val _isPremiumUser = MutableLiveData(false) val isPremiumUser: LiveData = _isPremiumUser private val _isLoading = MutableLiveData() @@ -43,23 +56,75 @@ class RevenueCatManager private constructor() { val error: LiveData = _error private var currentOffering: Offering? = null - private var premiumPackage: Package? = null + + /** Every plan the paywall can offer, ordered annual → monthly → lifetime. */ + private val _plans = MutableLiveData>(emptyList()) + val plans: LiveData> = _plans + + /** The plan the paywall pre-selects. Annual when present — it is the one we want bought. */ + val defaultPlan: PremiumPlan? get() = _plans.value?.firstOrNull { it.isRecommended } ?: _plans.value?.firstOrNull() + + /** One purchasable plan, flattened out of a [Package] so the UI never touches SDK types. */ + data class PremiumPlan( + val id: String, + val analyticsPlan: String, + val title: String, + val price: String, + val pricePerMonth: String?, + val savingPercent: Int?, + val isRecommended: Boolean, + val priceAmount: Double?, + val currencyCode: String?, + internal val rcPackage: Package, + ) interface SubscriptionCallback { fun onSuccess(message: String) fun onError(error: String) fun onUserCancelled() + + /** [onError] with a stable error code; the localized message alone is useless as a GA4 dimension. */ + fun onError(code: String, message: String) = onError(message) } /** - * Initialize RevenueCat SDK with Firebase user ID - * This must be called BEFORE any purchase operations + * Configure for a signed-OUT user so the paywall works before anyone logs in; the anonymous + * customer is merged into the Firebase UID by [initialize]'s `logIn()` on the next sign-in. */ + fun configureAnonymous(context: Context) { + if (isConfigured) return + try { + Purchases.configure( + PurchasesConfiguration.Builder(context, apiKey()) + .purchasesAreCompletedBy(PurchasesAreCompletedBy.REVENUECAT) + .build() + ) + attachCustomerInfoListener() + isConfigured = true + Log.i(TAG, "subs-flow: RevenueCat configured anonymously (no Firebase user yet)") + fetchCustomerInfoAndOfferings(null) + } catch (e: Exception) { + Log.e(TAG, "subs-flow: anonymous configure failed: ${e.message}", e) + } + } + + private fun apiKey(): String = BuildConfig.REVENUECAT_API_KEY + + private fun attachCustomerInfoListener() { + Purchases.sharedInstance.updatedCustomerInfoListener = + UpdatedCustomerInfoListener { customerInfo -> + Log.d(TAG, "Customer Info Updated: ${customerInfo.originalAppUserId}") + updatePremiumStatus(customerInfo) + } + } + + /** Identify the signed-in user; with no Firebase user this falls through to [configureAnonymous]. */ fun initialize(context: Context, firebaseAuth: FirebaseAuth, callback: SubscriptionCallback? = null) { val currentUser = firebaseAuth.currentUser if (currentUser == null) { - Log.e(TAG, "Cannot initialize RevenueCat: User not authenticated") - callback?.onError("User not authenticated") + Log.d(TAG, "subs-flow: no Firebase user — configuring RevenueCat anonymously") + configureAnonymous(context) + callback?.onSuccess("Premium services initialized") return } @@ -71,11 +136,7 @@ class RevenueCatManager private constructor() { // Configure RevenueCat SDK with the Firebase user ID if (!isConfigured) { try { - val apiKey = if (BuildConfig.DEBUG) { - "goog_HqifnUJxdgpKcyrUFhRfJfAYIap" - } else { - "goog_HqifnUJxdgpKcyrUFhRfJfAYIap" - } + val apiKey = apiKey() Purchases.configure( PurchasesConfiguration.Builder(context, apiKey) @@ -85,11 +146,7 @@ class RevenueCatManager private constructor() { ) // Setup customer info update listener - Purchases.sharedInstance.updatedCustomerInfoListener = - UpdatedCustomerInfoListener { customerInfo -> - Log.d(TAG, "Customer Info Updated: ${customerInfo.originalAppUserId}") - updatePremiumStatus(customerInfo) - } + attachCustomerInfoListener() isConfigured = true Log.d(TAG, "RevenueCat SDK configured successfully with user: $userId") @@ -188,25 +245,26 @@ class RevenueCatManager private constructor() { Purchases.sharedInstance.getOfferings(object : ReceiveOfferingsCallback { override fun onReceived(offerings: Offerings) { _isLoading.value = false - currentOffering = offerings.current + // Fall back to `current` so a build shipped before pro_v2 is promoted still sells. + val offering = offerings.getOffering(OFFERING_ID) ?: offerings.current + currentOffering = offering - if (currentOffering == null) { - val errorMsg = "Premium subscription not available" - _error.value = errorMsg - callback?.onError(errorMsg) + if (offering == null) { + _error.value = "Premium subscription not available" + callback?.onError(ERROR_NO_OFFERING, "Premium subscription not available") return } - // Look for monthly premium package - premiumPackage = currentOffering?.monthly ?: currentOffering?.availablePackages?.firstOrNull() + val built = buildPlans(offering) + _plans.value = built - if (premiumPackage != null) { - println("RevenueCat: Premium package loaded - ${premiumPackage?.product?.title}") + if (built.isNotEmpty()) { + Log.i(TAG, "subs-flow: ${built.size} plan(s) from '${offering.identifier}': " + + built.joinToString { "${it.analyticsPlan}=${it.price}" }) callback?.onSuccess("Premium services initialized") } else { - val errorMsg = "Premium subscription not available" - _error.value = errorMsg - callback?.onError(errorMsg) + _error.value = "Premium subscription not available" + callback?.onError(ERROR_NO_PACKAGE, "Premium subscription not available") } } @@ -214,19 +272,79 @@ class RevenueCatManager private constructor() { _isLoading.value = false val errorMsg = "Failed to load premium options: ${error.message}" _error.value = errorMsg - callback?.onError(errorMsg) + callback?.onError(error.code.name, errorMsg) } }) } + /** Flatten an [Offering] into plans. The saving uses real per-month prices — Play's regional + * tiers are not a constant multiple. */ + private fun buildPlans(offering: Offering): List { + val annual = offering.annual + val monthly = offering.monthly + val lifetime = offering.lifetime + + val monthlyMicros = monthly?.product?.price?.amountMicros + val annualPerMonthMicros = annual?.product?.pricePerMonth()?.amountMicros + + val saving = if (monthlyMicros != null && annualPerMonthMicros != null && monthlyMicros > 0) { + (100 - (annualPerMonthMicros * 100 / monthlyMicros)).toInt().takeIf { it > 0 } + } else null + + fun plan(pkg: Package?, analytics: String, recommended: Boolean): PremiumPlan? { + val p = pkg ?: return null + val isLifetime = analytics == AppAnalytics.Plan.LIFETIME + return PremiumPlan( + id = p.identifier, + analyticsPlan = analytics, + title = p.product.title, + price = p.product.price.formatted, + pricePerMonth = if (isLifetime) null else p.product.formattedPricePerMonth(), + savingPercent = if (analytics == AppAnalytics.Plan.ANNUAL) saving else null, + isRecommended = recommended, + priceAmount = p.product.price.amountMicros / 1_000_000.0, + currencyCode = p.product.price.currencyCode, + rcPackage = p, + ) + } + + val plans = listOfNotNull( + plan(annual, AppAnalytics.Plan.ANNUAL, recommended = true), + plan(monthly, AppAnalytics.Plan.MONTHLY, recommended = false), + plan(lifetime, AppAnalytics.Plan.LIFETIME, recommended = false), + ) + + // The live `sale` offering types its monthly product as `$rc_weekly`, so no typed + // accessor matches it — fall back to whatever is there rather than show nothing. + if (plans.isEmpty()) { + return offering.availablePackages.map { + PremiumPlan( + id = it.identifier, + analyticsPlan = AppAnalytics.Plan.UNKNOWN, + title = it.product.title, + price = it.product.price.formatted, + pricePerMonth = null, + savingPercent = null, + isRecommended = true, + priceAmount = it.product.price.amountMicros / 1_000_000.0, + currencyCode = it.product.price.currencyCode, + rcPackage = it, + ) + } + } + return plans + } + /** * Check current customer info and update premium status * Note: RevenueCat must be initialized first */ fun refreshCustomerInfo(callback: SubscriptionCallback? = null) { if (!isConfigured) { + // Must fire the callback; returning silently left callers waiting forever. Log.w(TAG, "Cannot refresh customer info: RevenueCat not initialized") _isPremiumUser.value = false + callback?.onError(ERROR_NOT_CONFIGURED, "Premium is still starting up") return } @@ -262,7 +380,7 @@ class RevenueCatManager private constructor() { fun syncPurchases(callback: SubscriptionCallback? = null) { if (!isConfigured) { Log.w(TAG, "Cannot sync purchases: RevenueCat not initialized") - callback?.onError("RevenueCat not initialized") + callback?.onError(ERROR_NOT_CONFIGURED, "Premium is still starting up") return } @@ -281,21 +399,25 @@ class RevenueCatManager private constructor() { }) } - /** - * Purchase premium subscription - */ - fun purchasePremium(activity: Activity, callback: SubscriptionCallback) { + /** Buy [plan], or the recommended one. No Firebase user required — see [configureAnonymous]. */ + @JvmOverloads + fun purchasePremium( + activity: Activity, + plan: PremiumPlan? = null, + callback: SubscriptionCallback, + ) { if (!isConfigured) { - val errorMsg = "RevenueCat not initialized. Please login first." - Log.e(TAG, errorMsg) - callback.onError(errorMsg) + // Configured at app start, so this is a genuine failure — never ask the user to log in. + val errorMsg = "Premium is still starting up. Please try again in a moment." + Log.e(TAG, "subs-flow: purchase blocked — SDK not configured") + callback.onError(ERROR_NOT_CONFIGURED, errorMsg) return } - val packageToPurchase = premiumPackage + val packageToPurchase = (plan ?: defaultPlan)?.rcPackage if (packageToPurchase == null) { Log.e(TAG, "Premium package not available") - callback.onError("Premium subscription not available") + callback.onError(ERROR_NO_PACKAGE, "Premium subscription not available") return } @@ -348,12 +470,12 @@ class RevenueCatManager private constructor() { } error.code == PurchasesErrorCode.ProductAlreadyPurchasedError -> { Log.d(TAG, "Product already purchased, refreshing customer info") - callback.onError("You already own this subscription") + callback.onError(error.code.name, "You already own this subscription") // Refresh customer info to update UI refreshCustomerInfo() } else -> { - callback.onError("Purchase failed: ${error.message}") + callback.onError(error.code.name, "Purchase failed: ${error.message}") } } } @@ -365,6 +487,12 @@ class RevenueCatManager private constructor() { * Logout from RevenueCat */ fun logout(callback: SubscriptionCallback? = null) { + // `sharedInstance` throws when never configured, and AuthManager.signOut() calls in here. + if (!isConfigured) { + _isPremiumUser.value = false + callback?.onSuccess("Logged out successfully") + return + } Purchases.sharedInstance.logOut(object : ReceiveCustomerInfoCallback { override fun onReceived(customerInfo: CustomerInfo) { _isPremiumUser.value = false @@ -427,16 +555,6 @@ class RevenueCatManager private constructor() { private fun currentAppUserId(customerInfo: CustomerInfo): String = runCatching { Purchases.sharedInstance.appUserID }.getOrDefault(customerInfo.originalAppUserId) - /** - * Get premium package details for UI display - */ - fun getPremiumPackageInfo(): Pair { - return Pair( - premiumPackage?.product?.title, - premiumPackage?.product?.price?.formatted - ) - } - /** * Check if user is premium without triggering network call */ diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/UserRepository.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/UserRepository.kt index 7f137d6..5e5baa3 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/UserRepository.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/UserRepository.kt @@ -87,56 +87,8 @@ class UserRepository { } } - /** - * Update user subscription - */ - suspend fun updateSubscription( - uid: String, - subscriptionType: SubscriptionType, - startDate: Long = System.currentTimeMillis(), - endDate: Long? = null - ): Result { - return try { - val updates = mapOf( - "isSubscribed" to (subscriptionType != SubscriptionType.FREE), - "subscriptionType" to subscriptionType, - "subscriptionStartDate" to startDate, - "subscriptionEndDate" to endDate - ) - - usersCollection.document(uid) - .update(updates) - .await() - - // Track subscription event - trackUserEvent(uid, "subscription_updated", mapOf( - "subscription_type" to subscriptionType.name, - "is_subscribed" to (subscriptionType != SubscriptionType.FREE) - )) - - Result.success(true) - } catch (e: Exception) { - Result.failure(e) - } - } - - /** - * Check if user has premium access - */ - suspend fun hasePremiumAccess(uid: String): Boolean { - return try { - val user = getUserById(uid) - when { - user == null -> false - user.subscriptionType == SubscriptionType.LIFETIME -> true - user.subscriptionType == SubscriptionType.FREE -> false - user.subscriptionEndDate == null -> false - else -> System.currentTimeMillis() < user.subscriptionEndDate!! - } - } catch (e: Exception) { - false - } - } + // A dead second entitlement path (updateSubscription / hasePremiumAccess) was removed here: + // the backend owns users/{uid}.isPro and the client reads entitlement from RevenueCat. /** * Track user events for analytics diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/service/TibetanKeyboard.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/service/TibetanKeyboard.kt index 58fed8e..cc5400b 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/service/TibetanKeyboard.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/service/TibetanKeyboard.kt @@ -32,6 +32,7 @@ import com.kharagedition.tibetankeyboard.data.local.TypingStatsStore import com.kharagedition.tibetankeyboard.data.repository.RevenueCatManager import com.kharagedition.tibetankeyboard.data.repository.subscriptionCallback import com.kharagedition.tibetankeyboard.ui.keyboard.KeyboardType +import com.kharagedition.tibetankeyboard.ui.settings.SettingsPrefs import com.kharagedition.tibetankeyboard.util.AppConstant import com.kharagedition.tibetankeyboard.util.openPremiumUpgrade import com.kharagedition.tibetankeyboard.ui.chat.ChatActivity @@ -46,7 +47,6 @@ import com.kharagedition.tibetankeyboard.ui.keyboard.AIKeyboardCodes import com.kharagedition.tibetankeyboard.ui.keyboard.EmojiItemList import com.kharagedition.tibetankeyboard.ui.keyboard.StickerItem import com.kharagedition.tibetankeyboard.R -import com.kharagedition.tibetankeyboard.FontsOverride class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboardInterface { @@ -141,7 +141,6 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa override fun onCreateInputView(): View { Log.i("TAG", "onCreateInputView: CALLED") prefs = PreferenceManager.getDefaultSharedPreferences(this) - setKeyBoardLanguage() // Create the main container with AI toolbar val mainContainer = createMainKeyboardView() @@ -170,8 +169,20 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa return mainContainer } + /** Colour the user is entitled to; cached entitlement keeps this synchronous on the hot path. */ + private fun entitledColor(): String = SettingsPrefs.effectiveColor( + prefs.getString(SettingsPrefs.KEY_COLOR, SettingsPrefs.DEFAULT_COLOR) ?: SettingsPrefs.DEFAULT_COLOR, + RevenueCatManager.getInstance().isPremiumUserCached(), + ) + + /** Layout the user is entitled to. See [entitledColor]. */ + private fun entitledStyle(): String = SettingsPrefs.effectiveStyle( + prefs.getString(SettingsPrefs.KEY_STYLE, SettingsPrefs.DEFAULT_STYLE) ?: SettingsPrefs.DEFAULT_STYLE, + RevenueCatManager.getInstance().isPremiumUserCached(), + ) + private fun createMainKeyboardView(): View { - val color = prefs.getString("colors", "#FF704C04") + val color = entitledColor() // Create AIKeyboardView directly aiKeyboardView = AIKeyboardView(this) @@ -181,15 +192,9 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa return aiKeyboardView!! } - private fun setKeyBoardLanguage() { - // Uchen only — never the cursive Ume/Tsutong face. Use the system Tibetan - // font (renders Uchen) so Latin UI text stays clean too. - FontsOverride.setDefaultFont(this, "DEFAULT", null) - } - private fun setKeyBoardView() { - val color = prefs.getString("colors", "#FF704C04") - val keyboardStyle = prefs.getString("keyboard_style", "borderless") + val color = entitledColor() + val keyboardStyle = entitledStyle() if (keyboardStyle == "borderless") { // Borderless is colour-agnostic: one transparent-key layout, surface tint @@ -353,7 +358,7 @@ class TibetanKeyboard : InputMethodService(), OnKeyboardActionListener, AIKeyboa AppAnalytics.logKeyboardEmojiOpened() if (emojiKeyboardView == null) { emojiKeyboardView = EmojiKeyboardView(this) - val themeColor = prefs.getString("colors", "#FF704C04") ?: "#FF704C04" + val themeColor = entitledColor() emojiKeyboardView?.setThemeColor(themeColor) diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeActivity.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeActivity.kt index 6603c4f..7fd6581 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeActivity.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeActivity.kt @@ -35,6 +35,7 @@ import com.kharagedition.tibetankeyboard.BuildConfig import com.kharagedition.tibetankeyboard.InAppUpdateManager import com.kharagedition.tibetankeyboard.R import com.kharagedition.tibetankeyboard.analytics.AppAnalytics +import com.kharagedition.tibetankeyboard.auth.AuthManager import com.kharagedition.tibetankeyboard.ads.NativeTemplateStyle import com.kharagedition.tibetankeyboard.ads.TemplateView import com.kharagedition.tibetankeyboard.app.InputMethodActivity @@ -206,16 +207,21 @@ class HomeActivity : InputMethodActivity() { ) /** - * PRO feature entry points. Subscribers get the screen. Signed-in free users get the - * paywall. Signed-out users can't be classified yet, so we open the screen — it routes - * them through login and back, after which a free user is gated on the first action. + * PRO entry points (Chat, Translate). Signed-out users are classifiable now that RevenueCat is + * configured anonymously; entitled-but-anonymous users sign in here because the server needs a UID. */ private fun openIfPremiumOrUpgrade(target: Class<*>) { - if (viewModel.uiState.value.isPremium || !viewModel.isUserAuthenticated()) { - startActivity(Intent(this, target)) - } else { + if (!viewModel.uiState.value.isPremium) { + AppAnalytics.logFeatureGateShown(AppAnalytics.UpgradeSource.HOME_FEATURE_GATE) openPremiumUpgrade(AppAnalytics.UpgradeSource.HOME_FEATURE_GATE) + return } + if (!viewModel.isUserAuthenticated()) { + // Link the account first, then return to the screen they asked for. + AuthManager(this).redirectToLogin(finishCaller = false, target = target) + return + } + startActivity(Intent(this, target)) } /** Loads/destroys the native ad as premium status changes (ad Views can't live in the VM). */ diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeScreen.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeScreen.kt index fd64bfb..b94df1c 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeScreen.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import com.bumptech.glide.Glide import com.kharagedition.tibetankeyboard.R +import com.kharagedition.tibetankeyboard.analytics.AppAnalytics import com.kharagedition.tibetankeyboard.ui.compose.components.AppIcons import com.kharagedition.tibetankeyboard.ui.compose.components.BoText import com.kharagedition.tibetankeyboard.ui.compose.components.IconTile @@ -133,10 +134,11 @@ fun HomeScreen( ) Spacer(Modifier.height(14.dp)) - if (state.keyboardEnabled && state.inputMethodSelected) { - TestKeyboardField() - } else { - // SetupDemo(if (!state.keyboardEnabled) R.drawable.keyboard else R.drawable.input) + if (state.keyboardEnabled) { + TestKeyboardField( + inputMethodSelected = state.inputMethodSelected, + onPickInputMethod = actions.onPickInputMethod, + ) } // The streak banner is the Journey's Home-screen trigger — always present once @@ -529,8 +531,13 @@ private fun SetupDemo(gifResId: Int) { /** Inline field so the user can test the keyboard without leaving Home. */ @Composable -private fun TestKeyboardField() { +private fun TestKeyboardField( + inputMethodSelected: Boolean, + onPickInputMethod: () -> Unit, +) { var text by remember { mutableStateOf("") } + var logged by remember { mutableStateOf(false) } + Column( modifier = Modifier .fillMaxWidth() @@ -559,11 +566,50 @@ private fun TestKeyboardField() { } BasicTextField( value = text, - onValueChange = { text = it }, + onValueChange = { + text = it + // First keystroke only; reports the script, never the characters. + if (!logged && it.isNotEmpty()) { + logged = true + AppAnalytics.logKeyboardTryoutTyped( + if (it.any { c -> c in '\u0F00'..'\u0FFF' }) AppAnalytics.KeyboardLanguage.TIBETAN + else AppAnalytics.KeyboardLanguage.ENGLISH + ) + } + }, textStyle = TextStyle(color = TibetanColors.Cream, fontSize = 16.sp), cursorBrush = SolidColor(TibetanColors.Gold300), modifier = Modifier.fillMaxWidth(), ) } + + // Enabled but not selected: the field opens whichever keyboard is default, so name the + // problem and put the fix directly under it. + if (!inputMethodSelected) { + Spacer(Modifier.height(12.dp)) + Text( + stringResource(R.string.setup_try_wrong_keyboard), + color = TibetanColors.CreamDim, fontSize = 12.5.sp, lineHeight = 17.sp, + ) + Spacer(Modifier.height(10.dp)) + Box( + modifier = Modifier + .clip(TibetanTokens.Pill) + .background(TibetanTokens.GoldVertical) + .clickable(onClick = onPickInputMethod) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { + Text( + stringResource(R.string.setup_switch_keyboard), + color = TibetanColors.Espresso, fontSize = 12.5.sp, fontWeight = FontWeight.Bold, + ) + } + } else if (text.isNotEmpty()) { + Spacer(Modifier.height(10.dp)) + Text( + stringResource(R.string.setup_try_working), + color = TibetanColors.Jade, fontSize = 12.5.sp, fontWeight = FontWeight.Bold, + ) + } } } diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeViewModel.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeViewModel.kt index 863b4de..d7d063a 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeViewModel.kt @@ -3,6 +3,7 @@ package com.kharagedition.tibetankeyboard.ui.home import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.Observer +import com.kharagedition.tibetankeyboard.analytics.AppAnalytics import com.kharagedition.tibetankeyboard.auth.AuthManager import com.kharagedition.tibetankeyboard.data.local.TypingStatsStore import com.kharagedition.tibetankeyboard.data.repository.RevenueCatManager @@ -36,6 +37,16 @@ class HomeViewModel(app: Application) : AndroidViewModel(app) { /** Called by the Activity after it reads keyboard-enabled / default-IME status. */ fun refreshSetup(keyboardEnabled: Boolean, inputMethodSelected: Boolean) { _uiState.update { it.copy(keyboardEnabled = keyboardEnabled, inputMethodSelected = inputMethodSelected) } + if (keyboardEnabled && inputMethodSelected) logSetupCompletedOnce() + } + + /** Once per install — `refreshSetup` runs on every resume, so the latch must be persisted. */ + private fun logSetupCompletedOnce() { + val prefs = getApplication() + .getSharedPreferences(SETUP_PREFS, Application.MODE_PRIVATE) + if (prefs.getBoolean(KEY_SETUP_LOGGED, false)) return + prefs.edit().putBoolean(KEY_SETUP_LOGGED, true).apply() + AppAnalytics.logKeyboardSetupCompleted() } /** Re-read the on-device streak numbers for the Journey banner (cheap; every resume). */ @@ -57,4 +68,9 @@ class HomeViewModel(app: Application) : AndroidViewModel(app) { override fun onCleared() { premiumLiveData.removeObserver(premiumObserver) } + + private companion object { + const val SETUP_PREFS = "keyboard_setup" + const val KEY_SETUP_LOGGED = "setup_completed_logged" + } } diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsActivity.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsActivity.kt index 0ab84b0..322dbc4 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsActivity.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsActivity.kt @@ -5,6 +5,8 @@ import androidx.activity.compose.setContent import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -12,6 +14,7 @@ import com.google.android.gms.ads.AdRequest import com.google.android.gms.ads.AdSize import com.google.android.gms.ads.AdView import com.kharagedition.tibetankeyboard.BuildConfig +import com.kharagedition.tibetankeyboard.ui.subscription.ManageSubscriptionActivity import com.kharagedition.tibetankeyboard.R import com.kharagedition.tibetankeyboard.analytics.AppAnalytics import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanKeyboardTheme @@ -29,7 +32,13 @@ class SettingsActivity : AppCompatActivity() { setContent { TibetanKeyboardTheme { val state by viewModel.uiState.collectAsStateWithLifecycle() - SettingsScreen(state = state, actions = settingsActions(), adSlot = { BannerAd() }) + SettingsScreen( + state = state, + actions = settingsActions(), + // Null for subscribers so the AdView is never built; hiding it in the + // composable still called loadAd. Mirrors HomeActivity.observeAdGating(). + adSlot = if (state.isPremium) null else ({ BannerAd() }), + ) } } } @@ -49,6 +58,7 @@ class SettingsActivity : AppCompatActivity() { onNotification = viewModel::setNotification, onStreakReminder = viewModel::setStreakReminder, onUpgrade = { openPremiumUpgrade(AppAnalytics.UpgradeSource.SETTINGS) }, + onManageSubscription = { ManageSubscriptionActivity.open(this) }, onLogout = { showConfirmationDialog( title = getString(R.string.sign_out), @@ -68,17 +78,22 @@ class SettingsActivity : AppCompatActivity() { @Composable private fun BannerAd() { - AndroidView(factory = { ctx -> - AdView(ctx).apply { + // AdView holds a WebView and timers, so it must be paused/resumed and destroyed. + val adView = remember { + AdView(this).apply { setAdSize(AdSize.BANNER) - adUnitId = if (BuildConfig.DEBUG) { - TEST_BANNER_AD_UNIT - } else { - PROD_BANNER_AD_UNIT - } + adUnitId = if (BuildConfig.DEBUG) TEST_BANNER_AD_UNIT else PROD_BANNER_AD_UNIT loadAd(AdRequest.Builder().build()) } - }) + } + DisposableEffect(adView) { + adView.resume() + onDispose { + adView.pause() + adView.destroy() + } + } + AndroidView(factory = { adView }) } companion object { diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsPrefs.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsPrefs.kt index 5b256a9..c125cf2 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsPrefs.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsPrefs.kt @@ -85,6 +85,17 @@ object SettingsPrefs { /** True when [value] is a PRO-only keyboard layout. */ fun isStylePremium(value: String) = styleOptions.firstOrNull { it.value == value }?.premium == true + /** + * The colour to actually apply. Never rewrites the saved preference, so resubscribing restores + * the choice; the IME reads this store directly, so the check cannot live in the UI alone. + */ + fun effectiveColor(value: String, isPremium: Boolean): String = + if (!isPremium && isColorPremium(value)) DEFAULT_COLOR else value + + /** The keyboard layout to actually apply. See [effectiveColor]. */ + fun effectiveStyle(value: String, isPremium: Boolean): String = + if (!isPremium && isStylePremium(value)) DEFAULT_STYLE else value + private fun prefs(context: Context): SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsScreen.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsScreen.kt index dc1209a..bfc63f6 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -71,6 +72,7 @@ class SettingsActions( val onNotification: (Boolean) -> Unit, val onStreakReminder: (Boolean) -> Unit, val onUpgrade: () -> Unit, + val onManageSubscription: () -> Unit, val onLogout: () -> Unit, ) @@ -149,17 +151,31 @@ fun SettingsScreen( icon = AppIcons.Crown, title = stringResource(R.string.subscription), subtitle = stringResource(if (state.isPremium) R.string.premium_plan else R.string.free_plan), + // "Upgrade" for free users, Customer Center for subscribers — who + // previously had no in-app route to cancel or restore. trailing = { - if (!state.isPremium) { - Box( - modifier = Modifier - .clip(TibetanTokens.Pill) - .background(TibetanTokens.GoldVertical) - .clickable(onClick = actions.onUpgrade) - .padding(horizontal = 12.dp, vertical = 6.dp), - ) { - Text(stringResource(R.string.upgrade), color = TibetanColors.Espresso, fontSize = 12.5.sp, fontWeight = FontWeight.Bold) - } + Box( + modifier = Modifier + .clip(TibetanTokens.Pill) + .background( + if (state.isPremium) SolidColor(TibetanColors.Brown600) + else TibetanTokens.GoldVertical + ) + .clickable( + onClick = if (state.isPremium) actions.onManageSubscription + else actions.onUpgrade + ) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Text( + stringResource( + if (state.isPremium) R.string.manage_subscription + else R.string.upgrade + ), + color = if (state.isPremium) TibetanColors.Gold300 else TibetanColors.Espresso, + fontSize = 12.5.sp, + fontWeight = FontWeight.Bold, + ) } }, ) diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsViewModel.kt index 84c3a69..4394013 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsViewModel.kt @@ -34,10 +34,18 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) { RevenueCatManager.getInstance().refreshCustomerInfo() } - fun setColor(value: String) = write(SettingsPrefs.KEY_COLOR, value) { it.copy(color = value) } - .also { AppAnalytics.logSettingChanged(AppAnalytics.Setting.COLOR, value) } - fun setStyle(value: String) = write(SettingsPrefs.KEY_STYLE, value) { it.copy(style = value) } - .also { AppAnalytics.logSettingChanged(AppAnalytics.Setting.STYLE, value) } + // The picker disabling PRO rows was the only check; the IME reads this store directly. + fun setColor(value: String) { + if (!_uiState.value.isPremium && SettingsPrefs.isColorPremium(value)) return + write(SettingsPrefs.KEY_COLOR, value) { it.copy(color = value) } + AppAnalytics.logSettingChanged(AppAnalytics.Setting.COLOR, value) + } + + fun setStyle(value: String) { + if (!_uiState.value.isPremium && SettingsPrefs.isStylePremium(value)) return + write(SettingsPrefs.KEY_STYLE, value) { it.copy(style = value) } + AppAnalytics.logSettingChanged(AppAnalytics.Setting.STYLE, value) + } fun setVibrate(on: Boolean) = write(SettingsPrefs.KEY_VIBRATE, on) { it.copy(vibrate = on) } .also { AppAnalytics.logSettingChanged(AppAnalytics.Setting.VIBRATE, on.toString()) } fun setSound(on: Boolean) = write(SettingsPrefs.KEY_SOUND, on) { it.copy(sound = on) } diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/ManageSubscriptionActivity.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/ManageSubscriptionActivity.kt new file mode 100644 index 0000000..e94d42a --- /dev/null +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/ManageSubscriptionActivity.kt @@ -0,0 +1,50 @@ +package com.kharagedition.tibetankeyboard.ui.subscription + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.ui.Modifier +import com.kharagedition.tibetankeyboard.analytics.AppAnalytics +import com.kharagedition.tibetankeyboard.ui.compose.theme.TibetanColors +import com.revenuecat.purchases.ui.revenuecatui.customercenter.CustomerCenter + +/** + * RevenueCat's Customer Center: cancellation, restore, the retention offer and the exit survey + * (all configured in the dashboard). Refund Request and Change Plans are iOS-only and stay hidden. + */ +class ManageSubscriptionActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + AppAnalytics.logManageSubscriptionOpened() + setContent { + // CustomerCenter reads MaterialTheme, not TibetanKeyboardTheme's tokens, so seed a + // dark scheme with the app palette instead of Material's default purple. + MaterialTheme( + colorScheme = darkColorScheme( + primary = TibetanColors.Gold300, + onPrimary = TibetanColors.Espresso, + background = TibetanColors.Espresso, + onBackground = TibetanColors.Cream, + surface = TibetanColors.Brown700, + onSurface = TibetanColors.Cream, + ) + ) { + CustomerCenter(modifier = Modifier.fillMaxSize()) { finish() } + } + } + } + + companion object { + fun open(context: Context) { + val intent = Intent(context, ManageSubscriptionActivity::class.java) + if (context !is android.app.Activity) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } + } +} diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumActivity.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumActivity.kt index 031b33e..483712f 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumActivity.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumActivity.kt @@ -7,6 +7,9 @@ import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.kharagedition.tibetankeyboard.R import com.kharagedition.tibetankeyboard.analytics.AppAnalytics @@ -21,36 +24,53 @@ class PremiumActivity : AppCompatActivity() { private val viewModel: PremiumViewModel by viewModels() + /** The PRO lock that opened this paywall; carried into every purchase event for attribution. */ + private val upgradeSource: String + get() = intent?.getStringExtra(EXTRA_UPGRADE_SOURCE) ?: AppAnalytics.UpgradeSource.UNKNOWN + + /** Stops a purchase-driven close also counting as an abandon. */ + private var closedAfterPurchase = false + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - AppAnalytics.logPaywallViewed() setContent { TibetanKeyboardTheme { - val priceLabel by viewModel.priceLabel.collectAsStateWithLifecycle() - val isPremium by viewModel.isPremium.collectAsStateWithLifecycle() - // Premium users have nothing to buy — close the paywall. - LaunchedEffect(isPremium) { if (isPremium) closePaywall() } + val state by viewModel.uiState.collectAsStateWithLifecycle() + var viewLogged by rememberSaveable { mutableStateOf(false) } + // Premium users have nothing to buy, so they bounce without ever counting as a + // view — logging this in onCreate let them inflate the paywall's denominator. + LaunchedEffect(state.isPremium) { + if (state.isPremium) { + closedAfterPurchase = true + closePaywall() + } else if (!viewLogged) { + viewLogged = true + AppAnalytics.logPaywallViewed(upgradeSource) + } + } PremiumScreen( - priceLabel = priceLabel, - onBack = { closePaywall() }, - onPurchase = { purchasePremium() }, - onRestore = { restorePurchases() }, + state = state, + actions = PremiumActions( + onBack = { closePaywall() }, + onSelectPlan = { plan -> + viewModel.selectPlan(plan.id) + AppAnalytics.logPaywallPlanSelected(upgradeSource, plan.analyticsPlan) + }, + onPurchase = { purchasePremium() }, + onRestore = { restorePurchases() }, + ), ) } } } - override fun onResume() { - super.onResume() - viewModel.refreshPrice() - } - /** * Close the paywall without ever leaving an empty back stack. If this is the task root * (e.g. reached right after a login that finished the launching screen), go to Home. */ private fun closePaywall() { + if (!closedAfterPurchase) AppAnalytics.logPaywallDismissed(upgradeSource) if (isTaskRoot) { startActivity(Intent(this, HomeActivity::class.java)) } @@ -58,17 +78,60 @@ class PremiumActivity : AppCompatActivity() { } private fun purchasePremium() { - AppAnalytics.logPurchaseStarted() - RevenueCatManager.getInstance().purchasePremium(this, subscriptionCallback( - onSuccess = { AppAnalytics.logPurchaseCompleted(); showToast(it); closePaywall() }, - onError = { AppAnalytics.logPurchaseFailed(it); showToast(it) }, - )) + val plan = viewModel.selectedPlan() ?: return + AppAnalytics.logPurchaseStarted(upgradeSource, plan.analyticsPlan) + RevenueCatManager.getInstance().purchasePremium( + activity = this, + plan = plan, + callback = object : RevenueCatManager.SubscriptionCallback { + override fun onSuccess(message: String) { + AppAnalytics.logPurchaseCompleted( + source = upgradeSource, + plan = plan.analyticsPlan, + price = plan.priceAmount, + currency = plan.currencyCode, + ) + closedAfterPurchase = true + showToast(message) + closePaywall() + } + + override fun onError(error: String) = + onError(RevenueCatManager.ERROR_UNKNOWN, error) + + override fun onError(code: String, message: String) { + AppAnalytics.logPurchaseFailed(upgradeSource, plan.analyticsPlan, code, message) + showToast(message) + } + + // Dismissing Play's sheet fired nothing before, hiding the funnel's largest drop. + override fun onUserCancelled() { + AppAnalytics.logPurchaseCancelled(upgradeSource, plan.analyticsPlan) + } + }, + ) } private fun restorePurchases() { - RevenueCatManager.getInstance().syncPurchases(subscriptionCallback( - onSuccess = { AppAnalytics.logPurchaseRestored(); showToast(getString(R.string.purchases_restored)) }, - onError = { showToast(it) }, - )) + RevenueCatManager.getInstance().syncPurchases(object : RevenueCatManager.SubscriptionCallback { + override fun onSuccess(message: String) { + AppAnalytics.logPurchaseRestored() + showToast(getString(R.string.purchases_restored)) + } + + override fun onError(error: String) = onError(RevenueCatManager.ERROR_UNKNOWN, error) + + override fun onError(code: String, message: String) { + AppAnalytics.logRestoreFailed(code, message) + showToast(message) + } + + override fun onUserCancelled() {} + }) + } + + companion object { + /** One of [AppAnalytics.UpgradeSource]. Set by `AuthManager.openPremium(source)`. */ + const val EXTRA_UPGRADE_SOURCE = "upgrade_source" } } diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumScreen.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumScreen.kt index a993915..5cde74a 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumScreen.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumScreen.kt @@ -23,9 +23,12 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.kharagedition.tibetankeyboard.R +import com.kharagedition.tibetankeyboard.analytics.AppAnalytics +import com.kharagedition.tibetankeyboard.data.repository.RevenueCatManager.PremiumPlan import com.kharagedition.tibetankeyboard.ui.compose.components.AppIcons import com.kharagedition.tibetankeyboard.ui.compose.components.BackHeader import com.kharagedition.tibetankeyboard.ui.compose.components.GoldButton @@ -38,21 +41,41 @@ private data class Feature(val icon: ImageVector, val title: String, val desc: S @Composable fun PremiumScreen( - priceLabel: String, - onBack: () -> Unit, - onPurchase: () -> Unit, - onRestore: () -> Unit, + state: PremiumUiState, + actions: PremiumActions, ) { + // Ordered by real 28-day usage — keyboard 3,326 users, emoji 1,649, AI toolbar 1 — so ads, + // themes and the journey lead and AI is the bonus rather than the headline. val features = listOf( - Feature(AppIcons.NoAds, "Remove all ads", "A clean, distraction-free keyboard"), - Feature(AppIcons.Bot, "AI Chat assistant", "Chat & compose in Tibetan with AI"), - Feature(AppIcons.Sparkle, "Next-word suggestions", "Smart Tibetan word prediction"), - Feature(AppIcons.Palette, "Premium themes", "Exclusive keyboard layouts & colors"), - Feature(AppIcons.Translate, "AI Translate", "Tibetan ⇄ English ⇄ Chinese with AI"), + Feature( + AppIcons.NoAds, + stringResource(R.string.premium_feature_no_ads), + stringResource(R.string.premium_feature_no_ads_desc), + ), + Feature( + AppIcons.Palette, + stringResource(R.string.premium_feature_themes), + stringResource(R.string.premium_feature_themes_desc), + ), + Feature( + AppIcons.Sparkle, + stringResource(R.string.premium_feature_suggestions), + stringResource(R.string.premium_feature_suggestions_desc), + ), + Feature( + AppIcons.Crown, + stringResource(R.string.premium_feature_journey), + stringResource(R.string.premium_feature_journey_desc), + ), + Feature( + AppIcons.Bot, + stringResource(R.string.premium_feature_ai), + stringResource(R.string.premium_feature_ai_desc), + ), ) ScreenScaffold(bottomPadding = 16.dp) { - BackHeader(stringResource(R.string.premium_title), onBack = onBack) + BackHeader(stringResource(R.string.premium_title), onBack = actions.onBack) // hero Box( @@ -80,9 +103,9 @@ fun PremiumScreen( Text(stringResource(R.string.unlock_everything), color = TibetanColors.Cream, fontSize = 22.sp, fontWeight = FontWeight.ExtraBold) Spacer(Modifier.height(5.dp)) Text( - "Power up your Tibetan typing with AI,\nsmart suggestions and no ads.", + stringResource(R.string.premium_hero_subtitle), color = TibetanColors.Cream2, fontSize = 13.5.sp, lineHeight = 20.sp, - textAlign = androidx.compose.ui.text.style.TextAlign.Center, + textAlign = TextAlign.Center, ) } } @@ -104,42 +127,119 @@ fun PremiumScreen( } } - // price + cta Column(modifier = Modifier.padding(horizontal = 16.dp)) { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(TibetanColors.Brown600) - .border(1.5.dp, TibetanColors.Gold400, RoundedCornerShape(16.dp)) - .padding(horizontal = 16.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(Modifier.weight(1f)) { - Text(stringResource(R.string.monthly), color = TibetanColors.Cream, fontSize = 15.sp, fontWeight = FontWeight.Bold) - Text(stringResource(R.string.cancel_anytime), color = TibetanColors.CreamDim, fontSize = 12.sp) + if (state.plans.isEmpty()) { + Text( + stringResource(R.string.premium_loading_plans), + color = TibetanColors.CreamDim, fontSize = 13.sp, + modifier = Modifier.fillMaxWidth().padding(vertical = 18.dp), + textAlign = TextAlign.Center, + ) + } else { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + state.plans.forEach { plan -> + PlanCard( + plan = plan, + selected = plan.id == state.selectedPlanId, + onClick = { actions.onSelectPlan(plan) }, + ) + } } - Text(priceLabel, color = TibetanColors.Gold300, fontSize = 20.sp, fontWeight = FontWeight.ExtraBold) } - Spacer(Modifier.height(12.dp)) - GoldButton(stringResource(R.string.start_subscription), onClick = onPurchase) + Spacer(Modifier.height(14.dp)) + GoldButton( + if (state.plans.isEmpty()) stringResource(R.string.premium_continue) + else stringResource(R.string.start_subscription), + onClick = actions.onPurchase, + ) Spacer(Modifier.height(11.dp)) Text( stringResource(R.string.restore_purchase), color = TibetanColors.Gold300, fontSize = 12.5.sp, fontWeight = FontWeight.Bold, modifier = Modifier .fillMaxWidth() - .clickable(onClick = onRestore) + .clickable(onClick = actions.onRestore) .padding(vertical = 4.dp), - textAlign = androidx.compose.ui.text.style.TextAlign.Center, + textAlign = TextAlign.Center, ) Spacer(Modifier.height(4.dp)) Text( stringResource(R.string.terms_privacy), color = TibetanColors.CreamFaint, fontSize = 11.5.sp, modifier = Modifier.fillMaxWidth(), - textAlign = androidx.compose.ui.text.style.TextAlign.Center, + textAlign = TextAlign.Center, ) } } } + +/** One selectable plan; selection reads from the gold border and fill, not colour alone. */ +@Composable +private fun PlanCard( + plan: PremiumPlan, + selected: Boolean, + onClick: () -> Unit, +) { + // Keyed on the package type, not on savingPercent — an offering with annual but no monthly + // has nothing to compute a saving from and would otherwise label itself "Monthly". + val title = when (plan.analyticsPlan) { + AppAnalytics.Plan.LIFETIME -> stringResource(R.string.premium_lifetime) + AppAnalytics.Plan.ANNUAL -> stringResource(R.string.premium_annual) + AppAnalytics.Plan.MONTHLY -> stringResource(R.string.monthly) + else -> plan.title + } + val subtitle = when { + plan.analyticsPlan == AppAnalytics.Plan.LIFETIME -> stringResource(R.string.premium_pay_once) + plan.analyticsPlan != AppAnalytics.Plan.ANNUAL -> stringResource(R.string.cancel_anytime) + plan.pricePerMonth != null -> stringResource(R.string.premium_per_month, plan.pricePerMonth) + else -> stringResource(R.string.premium_billed_yearly) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(if (selected) TibetanColors.Brown600 else TibetanColors.Brown700) + .border( + if (selected) 1.5.dp else 1.dp, + if (selected) TibetanColors.Gold400 else TibetanColors.Gold200.copy(alpha = 0.22f), + RoundedCornerShape(16.dp), + ) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(title, color = TibetanColors.Cream, fontSize = 15.sp, fontWeight = FontWeight.Bold) + plan.savingPercent?.let { pct -> + Text( + stringResource(R.string.premium_save_badge, pct), + color = TibetanColors.Espresso, + fontSize = 10.sp, + fontWeight = FontWeight.ExtraBold, + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(TibetanTokens.GoldVerticalBright) + .padding(horizontal = 7.dp, vertical = 2.dp), + ) + } + } + Text(subtitle, color = TibetanColors.CreamDim, fontSize = 12.sp) + } + Column(horizontalAlignment = Alignment.End) { + Text( + plan.price, + color = if (selected) TibetanColors.Gold300 else TibetanColors.Cream2, + fontSize = 19.sp, + fontWeight = FontWeight.ExtraBold, + ) + if (plan.isRecommended) { + Text( + stringResource(R.string.premium_best_value), + color = TibetanColors.Gold300, fontSize = 9.5.sp, fontWeight = FontWeight.Bold, + ) + } + } + } +} diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumViewModel.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumViewModel.kt index 4c20228..68d9d4e 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumViewModel.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumViewModel.kt @@ -1,43 +1,75 @@ package com.kharagedition.tibetankeyboard.ui.subscription -import android.app.Application -import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModel import com.kharagedition.tibetankeyboard.data.repository.RevenueCatManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update -/** Holds the paywall's display state (price + premium status). Purchasing stays in the - * Activity since RevenueCat requires an Activity reference. */ -class PremiumViewModel(app: Application) : AndroidViewModel(app) { +/** What the paywall renders. */ +data class PremiumUiState( + val plans: List = emptyList(), + val selectedPlanId: String? = null, + val isPremium: Boolean = false, + val isLoading: Boolean = true, +) - private val premiumLiveData = RevenueCatManager.getInstance().isPremiumUser +/** Lambdas the paywall can invoke; mirrors the `XxxActions` convention of the other screens. */ +class PremiumActions( + val onBack: () -> Unit = {}, + val onSelectPlan: (RevenueCatManager.PremiumPlan) -> Unit = {}, + val onPurchase: () -> Unit = {}, + val onRestore: () -> Unit = {}, +) - private val _priceLabel = MutableStateFlow( - RevenueCatManager.getInstance().getPremiumPackageInfo().second ?: DEFAULT_PRICE - ) - val priceLabel: StateFlow = _priceLabel.asStateFlow() +/** + * Paywall state. Observes plans rather than reading once, so an offering that finishes loading + * after `onResume` still appears instead of leaving the screen on its placeholder. + */ +class PremiumViewModel : ViewModel() { - private val _isPremium = MutableStateFlow(false) - val isPremium: StateFlow = _isPremium.asStateFlow() + private val revenueCat = RevenueCatManager.getInstance() - private val premiumObserver = Observer { _isPremium.value = it } + private val _uiState = MutableStateFlow(PremiumUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val premiumObserver = Observer { isPremium -> + _uiState.update { it.copy(isPremium = isPremium) } + } + + private val plansObserver = Observer> { plans -> + _uiState.update { state -> + state.copy( + plans = plans, + // Keep the user's choice across refreshes, else fall back to the recommended plan. + selectedPlanId = state.selectedPlanId?.takeIf { id -> plans.any { it.id == id } } + ?: plans.firstOrNull { it.isRecommended }?.id + ?: plans.firstOrNull()?.id, + isLoading = plans.isEmpty(), + ) + } + } init { - premiumLiveData.observeForever(premiumObserver) - RevenueCatManager.getInstance().refreshCustomerInfo() + revenueCat.isPremiumUser.observeForever(premiumObserver) + revenueCat.plans.observeForever(plansObserver) + revenueCat.refreshCustomerInfo() } - fun refreshPrice() { - _priceLabel.value = RevenueCatManager.getInstance().getPremiumPackageInfo().second ?: DEFAULT_PRICE + fun selectPlan(planId: String) { + _uiState.update { it.copy(selectedPlanId = planId) } } - override fun onCleared() { - premiumLiveData.removeObserver(premiumObserver) + fun selectedPlan(): RevenueCatManager.PremiumPlan? { + val s = _uiState.value + return s.plans.firstOrNull { it.id == s.selectedPlanId } ?: s.plans.firstOrNull() } - companion object { - private const val DEFAULT_PRICE = "Premium" + override fun onCleared() { + revenueCat.isPremiumUser.removeObserver(premiumObserver) + revenueCat.plans.removeObserver(plansObserver) + super.onCleared() } } diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/util/TKExtension.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/util/TKExtension.kt index 4fcfb1e..a355784 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/util/TKExtension.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/util/TKExtension.kt @@ -29,7 +29,7 @@ fun Context.openPremiumUpgrade(source: String = AppAnalytics.UpgradeSource.UNKNO AppAnalytics.logUpgradeClicked(source) val authManager = AuthManager(this) when (UnlockRouter.destinationFor(authManager.isUserAuthenticated())) { - UnlockDestination.PREMIUM -> authManager.openPremium() + UnlockDestination.PREMIUM -> authManager.openPremium(source) // Keep the caller (Home/Settings) beneath so closing the paywall after login // can never leave an empty back stack and close the app. UnlockDestination.LOGIN_THEN_PREMIUM -> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 27439c2..e6d14f2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -18,7 +18,7 @@ Dictionary Favourite About - SettingsActivity + Settings Sound & Control @@ -227,6 +227,10 @@ Switch to Tibetan Keyboard Tap here, then pick Tibetan Keyboard from the popup to start typing. Got it + + Working — you\'re typing in Tibetan Keyboard. + That\'s a different keyboard. Tap \u201cSwitch keyboard\u201d below to pick Tibetan Keyboard. + Switch keyboard Tap the highlighted step to continue Setup guide overlay Design & UI @@ -254,6 +258,29 @@ Start subscription Restore purchase Terms · Privacy + Manage + + Annual + Lifetime + BEST VALUE + SAVE %1$d%% + %1$s / month + Pay once — yours forever + Billed once a year + Loading plans… + Continue + No ads, every theme, your full typing story — plus AI when you want it. + + Remove all ads + A clean, distraction-free keyboard + Every theme and layout + All keyboard colours and key styles + Next-word suggestions + Smart Tibetan word prediction as you type + Full typing journey + Streak history, weekly insights and vocabulary + AI chat and translate + Compose and translate Tibetan with AI About this app MORE FROM KHARAGEDITION GitHub diff --git a/app/src/test/java/com/kharagedition/tibetankeyboard/FontsOverrideTest.kt b/app/src/test/java/com/kharagedition/tibetankeyboard/FontsOverrideTest.kt new file mode 100644 index 0000000..67601aa --- /dev/null +++ b/app/src/test/java/com/kharagedition/tibetankeyboard/FontsOverrideTest.kt @@ -0,0 +1,17 @@ +package com.kharagedition.tibetankeyboard + +import org.junit.Assert.assertFalse +import org.junit.Test + +/** + * Guards the crash that took crash-free users to 69.5%: writing null into a static [android.graphics.Typeface] + * field poisons it for the whole process, and the IME shares its process with every Compose screen. + * A font that failed to load must leave the system default alone. + */ +class FontsOverrideTest { + + @Test + fun nullTypeface_isNeverWrittenToTheStaticField() { + assertFalse(FontsOverride.shouldReplace(null)) + } +} diff --git a/app/src/test/java/com/kharagedition/tibetankeyboard/auth/UnlockRouterTest.kt b/app/src/test/java/com/kharagedition/tibetankeyboard/auth/UnlockRouterTest.kt index 45b07be..8905aaa 100644 --- a/app/src/test/java/com/kharagedition/tibetankeyboard/auth/UnlockRouterTest.kt +++ b/app/src/test/java/com/kharagedition/tibetankeyboard/auth/UnlockRouterTest.kt @@ -4,15 +4,16 @@ import org.junit.Assert.assertEquals import org.junit.Test /** - * Guards the unlock routing: a free user tapping a PRO control must reach the paywall, going via - * login first only when signed out. + * Guards the unlock routing: tapping a PRO control must reach the paywall directly, signed in or + * not. Requiring a login first cost 61% of upgrade intents (2,454 taps → 956 paywall views), and + * Google Play does not need an account to take a payment. */ class UnlockRouterTest { @Test - fun signedOutUser_goesToLoginThenPremium() { + fun signedOutUser_goesStraightToPremium() { assertEquals( - UnlockDestination.LOGIN_THEN_PREMIUM, + UnlockDestination.PREMIUM, UnlockRouter.destinationFor(isAuthenticated = false) ) } diff --git a/app/src/test/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsPrefsTest.kt b/app/src/test/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsPrefsTest.kt new file mode 100644 index 0000000..5699d92 --- /dev/null +++ b/app/src/test/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsPrefsTest.kt @@ -0,0 +1,48 @@ +package com.kharagedition.tibetankeyboard.ui.settings + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Guards the PRO theme gate. Enforcing it only in the Settings picker let a lapsed subscriber keep + * a premium theme forever, because the IME reads the preference store directly and never re-checked + * the entitlement. + */ +class SettingsPrefsTest { + + @Test + fun freeUser_fallsBackToTheFreeDefaults() { + assertEquals( + SettingsPrefs.DEFAULT_COLOR, + SettingsPrefs.effectiveColor(SettingsPrefs.COLOR_BLACK, isPremium = false) + ) + assertEquals( + SettingsPrefs.DEFAULT_STYLE, + SettingsPrefs.effectiveStyle(SettingsPrefs.STYLE_CLASSIC, isPremium = false) + ) + } + + @Test + fun subscriber_keepsTheirChoice() { + assertEquals( + SettingsPrefs.COLOR_BLACK, + SettingsPrefs.effectiveColor(SettingsPrefs.COLOR_BLACK, isPremium = true) + ) + assertEquals( + SettingsPrefs.STYLE_CLASSIC, + SettingsPrefs.effectiveStyle(SettingsPrefs.STYLE_CLASSIC, isPremium = true) + ) + } + + @Test + fun freeChoicesAreNeverTouched() { + assertEquals( + SettingsPrefs.COLOR_BROWN, + SettingsPrefs.effectiveColor(SettingsPrefs.COLOR_BROWN, isPremium = false) + ) + assertEquals( + SettingsPrefs.STYLE_BORDERLESS, + SettingsPrefs.effectiveStyle(SettingsPrefs.STYLE_BORDERLESS, isPremium = false) + ) + } +} diff --git a/tools/firestore_rules_test.py b/tools/firestore_rules_test.py new file mode 100644 index 0000000..a13709a --- /dev/null +++ b/tools/firestore_rules_test.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +""" +Assert what api-backend/firestore.rules allows, against Firebase's rules simulator. + + python3 tools/firestore_rules_test.py [--deployed] # local file, or what is live + +Needs application-default credentials. The simulator wants new data at `request.resource.data`, +not `request.data` — get that wrong and every rule reading `request.resource` throws, which reads +as a denial, so DENY cases pass for the wrong reason and ALLOW cases fail. +""" +from __future__ import annotations + +import argparse +import json +import pathlib +import subprocess +import sys +import urllib.error +import urllib.request + +PROJECT = "tibetan-keyboard" +# Resolved from this file, so the script runs from anywhere (npm runs it with cwd=api-backend). +RULES_FILE = str(pathlib.Path(__file__).resolve().parent.parent / "api-backend" / "firestore.rules") + +UID = "abc123" +DOC = f"/databases/(default)/documents/users/{UID}" +OTHER = "/databases/(default)/documents/users/someone_else" +ANALYTICS = "/databases/(default)/documents/user_analytics/evt1" +JOURNEY = f"/databases/(default)/documents/journey_stats/{UID}" + +PROFILE = {"uid": UID, "displayName": "T", "email": "t@e.com", "isSubscribed": False} +PRO_PROFILE = {**PROFILE, "isPro": True} + + +def case(name, expectation, method, path, auth=None, new=None, old=None): + request = {"path": path, "method": method} + if auth: + request["auth"] = {"uid": auth} + if new is not None: + request["resource"] = {"data": new} + test = {"expectation": expectation, "request": request, "pathEncoding": "PLAIN"} + if old is not None: + test["resource"] = {"data": old} + return name, test + + +CASES = [ + # The exposure that was live. + case("anonymous reads a profile", "DENY", "get", DOC), + case("signed-in reads another user's profile", "DENY", "get", OTHER, auth=UID), + case("writing someone else's profile", "DENY", "create", OTHER, auth=UID, new=PROFILE), + # The entitlement the backend trusts. + case("create smuggling isPro", "DENY", "create", DOC, auth=UID, new=PRO_PROFILE), + case("update flipping isPro", "DENY", "update", DOC, auth=UID, new=PRO_PROFILE, old=PROFILE), + # Admin-SDK-only collections. + case("reads journey_stats", "DENY", "get", JOURNEY, auth=UID), + case("deletes own profile", "DENY", "delete", DOC, auth=UID), + # What the app genuinely does — these failing means sign-in is broken. + case("signed-in reads own profile", "ALLOW", "get", DOC, auth=UID), + case("login creates own profile", "ALLOW", "create", DOC, auth=UID, new=PROFILE), + case("activity ping updates own profile", "ALLOW", "update", DOC, auth=UID, + new={**PROFILE, "activity": {"activeDaysCount": 3}}, old=PROFILE), + case("update leaving an existing isPro untouched", "ALLOW", "update", DOC, auth=UID, + new={**PRO_PROFILE, "displayName": "T2"}, old=PRO_PROFILE), + case("appends a product-analytics event", "ALLOW", "create", ANALYTICS, auth=UID, + new={"event": "x"}), +] + + +def token() -> str: + out = subprocess.run( + ["gcloud", "auth", "application-default", "print-access-token"], + capture_output=True, text=True, timeout=60, + ) + if out.returncode != 0 or not out.stdout.strip(): + sys.exit("No access token. Run: gcloud auth application-default login") + return out.stdout.strip() + + +def api(path: str, tok: str, body: dict | None = None, method: str = "GET") -> dict: + req = urllib.request.Request( + f"https://firebaserules.googleapis.com/v1/{path}", + data=json.dumps(body).encode() if body else None, + method=method, + headers={"Authorization": f"Bearer {tok}", "Content-Type": "application/json", + "x-goog-user-project": PROJECT}, + ) + try: + with urllib.request.urlopen(req, timeout=60) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + sys.exit(f"{path} -> HTTP {e.code}\n{e.read().decode(errors='replace')}") + + +def deployed_source(tok: str) -> str: + releases = api(f"projects/{PROJECT}/releases", tok)["releases"] + ruleset = next(r for r in releases if r["name"].endswith("cloud.firestore"))["rulesetName"] + return api(f"{ruleset}", tok)["source"]["files"][0]["content"] + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--deployed", action="store_true", help="test the live ruleset, not the file") + args = ap.parse_args() + + tok = token() + source = deployed_source(tok) if args.deployed else open(RULES_FILE).read() + label = "deployed ruleset" if args.deployed else RULES_FILE + + names = [c[0] for c in CASES] + tests = [c[1] for c in CASES] + result = api( + f"projects/{PROJECT}:test", tok, method="POST", + body={"source": {"files": [{"name": "firestore.rules", "content": source}]}, + "testSuite": {"testCases": tests}}, + ) + + for issue in result.get("issues", []): + print("ISSUE:", issue.get("description")) + + failures = 0 + print(f"{label}\n") + for name, test, outcome in zip(names, tests, result.get("testResults", [])): + passed = outcome.get("state") == "SUCCESS" + failures += not passed + print(f" [{'PASS' if passed else 'FAIL'}] expect {test['expectation']:<5} · {name}") + if not passed and outcome.get("debugMessages"): + print(" ", outcome["debugMessages"][0][:160]) + + print(f"\n{len(CASES) - failures}/{len(CASES)} assertions pass") + sys.exit(1 if failures else 0) + + +if __name__ == "__main__": + main() diff --git a/tools/play_pricing.py b/tools/play_pricing.py new file mode 100644 index 0000000..afffc47 --- /dev/null +++ b/tools/play_pricing.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +""" +Price a subscription base plan from another one, region by region. + +Play's bulk price dialog converts one price at market FX, which breaks against a source plan +priced on local purchasing-power tiers — annual set from USD 19.99 came out dearer than 12x +monthly in 165 of 170 regions. This reads the source plan's real regional price and scales it. + + python3 tools/play_pricing.py --ratio 6.67 [--apply] + python3 tools/play_pricing.py --target-kind onetime --target lifetime_premium_unlock \\ + --target-plan lifetime-unlock --ratio 16.67 --max-months 24 --fix-above-months 20 + +Auth: gcloud auth application-default login \\ + --scopes=https://www.googleapis.com/auth/androidpublisher +(a plain `gcloud auth login` token is cloud-platform-scoped and 403s). +""" +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import urllib.error +import urllib.request +from decimal import ROUND_HALF_UP, Decimal + +API = "https://androidpublisher.googleapis.com/androidpublisher/v3" +PACKAGE = "com.kharagedition.tibetankeyboard" +# Play validates every price against a dated region/currency table and rejects anything older +# than the current one. +REGIONS_VERSION = "2025/03" + +# Subscriptions and one-time products hold the same idea (a plan with a price per region) behind +# entirely different names and endpoints, so everything below works through this table. +KINDS = { + "subscription": { + "path": "subscriptions", + "plans": "basePlans", + "plan_id": "basePlanId", + "configs": "regionalConfigs", + }, + "onetime": { + "path": "oneTimeProducts", + "plans": "purchaseOptions", + "plan_id": "purchaseOptionId", + "configs": "regionalPricingAndAvailabilityConfigs", + # One-time products have no PATCH — writes go through a batch endpoint that wraps the + # whole product in a request envelope. + "batch": True, + }, +} + +# Currencies with no minor unit — a price of "100" means 100, not 1.00. Rounding to X.99 would +# be meaningless (and rejected) for these. +ZERO_DECIMAL = { + "BIF", "CLP", "DJF", "GNF", "JPY", "KMF", "KRW", "MGA", "PYG", + "RWF", "UGX", "VND", "VUV", "XAF", "XOF", "XPF", +} + + +# --------------------------------------------------------------------------------------- money + + +def money_to_decimal(m: dict) -> Decimal: + """Play's Money {units, nanos} -> Decimal. units is a string int64; nanos is 1e-9 of a unit.""" + return Decimal(m.get("units", "0")) + Decimal(m.get("nanos", 0)) / Decimal(1_000_000_000) + + +def decimal_to_money(value: Decimal, currency: str) -> dict: + units = int(value) + nanos = int((value - units) * 1_000_000_000) + return {"currencyCode": currency, "units": str(units), "nanos": nanos} + + +def charm(value: Decimal, currency: str, source: Decimal) -> Decimal: + """ + Round a computed price to something a store would actually show. + + Zero-decimal currencies round to a whole number; a source price that is itself whole (INR + 60.00) keeps its target whole too, so INR 400 does not become INR 399.99; everything else + lands on X.99. + """ + if currency in ZERO_DECIMAL: + step = Decimal(100) if value >= 1000 else Decimal(10) + return (value / step).quantize(Decimal(1), rounding=ROUND_HALF_UP) * step + + source_is_whole = source == source.to_integral_value() + if source_is_whole: + return value.quantize(Decimal(1), rounding=ROUND_HALF_UP) + + whole = value.quantize(Decimal(1), rounding=ROUND_HALF_UP) + candidate = whole - Decimal("0.01") + return candidate if candidate > 0 else Decimal("0.99") + + +# ----------------------------------------------------------------------------------------- api + + +def token() -> str: + for args in ( + ["gcloud", "auth", "application-default", "print-access-token"], + ["gcloud", "auth", "print-access-token"], + ): + try: + out = subprocess.run(args, capture_output=True, text=True, timeout=60) + if out.returncode == 0 and out.stdout.strip(): + return out.stdout.strip() + except (OSError, subprocess.SubprocessError): + continue + sys.exit( + "No access token. Run:\n" + " gcloud auth application-default login " + "--scopes=https://www.googleapis.com/auth/androidpublisher" + ) + + +QUOTA_PROJECT: str | None = None + + +def call(method: str, path: str, tok: str, body: dict | None = None) -> dict: + headers = { + "Authorization": f"Bearer {tok}", + "Content-Type": "application/json", + } + # User (ADC) credentials have no project of their own, so Google needs one told to it for + # quota and for the API-enabled check. A header keeps it to this process rather than + # rewriting the machine's gcloud config. + if QUOTA_PROJECT: + headers["x-goog-user-project"] = QUOTA_PROJECT + req = urllib.request.Request( + f"{API}{path}", + method=method, + data=json.dumps(body).encode() if body is not None else None, + headers=headers, + ) + try: + with urllib.request.urlopen(req, timeout=60) as resp: + return json.loads(resp.read() or "{}") + except urllib.error.HTTPError as e: + detail = e.read().decode(errors="replace") + if e.code == 403 and "SCOPE" in detail: + sys.exit( + "403: the token lacks the androidpublisher scope. Run:\n" + " gcloud auth application-default login " + "--scopes=https://www.googleapis.com/auth/androidpublisher" + ) + sys.exit(f"{method} {path} -> HTTP {e.code}\n{detail}") + + +def find_plan(product: dict, kind: dict, plan_id: str) -> dict: + for plan in product.get(kind["plans"], []): + if plan.get(kind["plan_id"]) == plan_id: + return plan + sys.exit(f"plan '{plan_id}' not found; have: " + f"{[p.get(kind['plan_id']) for p in product.get(kind['plans'], [])]}") + + +# ---------------------------------------------------------------------------------------- main + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--package", default=PACKAGE) + ap.add_argument("--quota-project", default="tibetan-keyboard", + help="GCP project billed for the API call (ADC user creds need one)") + ap.add_argument("--regions-version", default=REGIONS_VERSION, + help="Play regions/currency table to validate against. Must be the latest one " + "Play knows, or currencies that have since changed (Bulgaria BGN -> EUR) " + "are rejected. Play names the current value in the 400 it returns.") + ap.add_argument("--source", default="monthly_premium_subscription", + help="subscription whose regional prices are the reference") + ap.add_argument("--source-plan", default="base-renew-plan") + ap.add_argument("--target", default="annual_premium_subscription") + ap.add_argument("--target-plan", default="annual-renew-plan") + ap.add_argument("--target-kind", choices=sorted(KINDS), default="subscription", + help="'onetime' for a one-time product such as the lifetime unlock") + ap.add_argument("--ratio", type=Decimal, required=True, + help="target = source x ratio (6.67 = annual at ~44%% off paying monthly)") + ap.add_argument("--max-months", type=Decimal, default=Decimal(12), + help="fail if the target ever costs more than this many months of the source") + ap.add_argument("--fix-above-months", type=Decimal, default=Decimal(10), + help="only reprice regions whose CURRENT target already costs more than this " + "many months of the source. Regions that are already a sensible discount " + "(the hand-checked US and India prices) are left exactly as they are.") + ap.add_argument("--all", action="store_true", + help="reprice every region, not just the broken ones") + ap.add_argument("--apply", action="store_true", help="write the prices (default: dry run)") + args = ap.parse_args() + + global QUOTA_PROJECT + QUOTA_PROJECT = args.quota_project + + tok = token() + + src_kind = KINDS["subscription"] + dst_kind = KINDS[args.target_kind] + + src = call("GET", f"/applications/{args.package}/{src_kind['path']}/{args.source}", tok) + dst = call("GET", f"/applications/{args.package}/{dst_kind['path']}/{args.target}", tok) + + src_plan = find_plan(src, src_kind, args.source_plan) + dst_plan = find_plan(dst, dst_kind, args.target_plan) + + src_prices = { + c["regionCode"]: c["price"] + for c in src_plan.get(src_kind["configs"], []) + if c.get("price") + } + if not src_prices: + sys.exit(f"no regional prices on {args.source}/{args.source_plan}") + + rows, new_configs, violations = [], [], [] + for cfg in dst_plan.get(dst_kind["configs"], []): + region = cfg["regionCode"] + src_price = src_prices.get(region) + if not src_price: + # Target sells somewhere the source does not; leave that region untouched. + new_configs.append(cfg) + continue + + currency = src_price["currencyCode"] + monthly = money_to_decimal(src_price) + current = money_to_decimal(cfg["price"]) if cfg.get("price") else None + current_months = (current / monthly) if (current is not None and monthly) else None + + # Leave regions alone when their current price is already a real discount — that covers + # the ones checked by hand, and every untouched region is one less thing to re-verify. + keep = ( + not args.all + and current_months is not None + and current_months <= args.fix_above_months + ) + if keep: + new_configs.append(cfg) + rows.append((region, currency, monthly, current, current_months, current, False)) + continue + + target = charm(monthly * args.ratio, currency, monthly) + months = (target / monthly) if monthly else Decimal(0) + + if months > args.max_months: + violations.append((region, currency, monthly, target, months)) + + rows.append((region, currency, monthly, target, months, current, True)) + updated = dict(cfg) + updated["price"] = decimal_to_money(target, currency) + new_configs.append(updated) + + rows.sort(key=lambda r: r[0]) + changed = [r for r in rows if r[6]] + print(f"{'REGION':<8}{'CUR':<5}{'MONTHLY':>12}{'PRICE':>14}{'WAS':>14}{'= MONTHS':>10} ") + print("-" * 66) + for region, currency, monthly, target, months, was, is_changed in rows: + was_s = f"{was:,.2f}" if was is not None else "-" + mark = "CHANGE" if is_changed else "" + print(f"{region:<8}{currency:<5}{monthly:>12,.2f}{target:>14,.2f}{was_s:>14}" + f"{months:>9.1f}x {mark}") + + print(f"\n{len(rows)} regions inspected · {len(changed)} would change · " + f"{len(rows) - len(changed)} already a sensible discount and left alone") + + if violations: + print(f"\nREFUSING: {len(violations)} region(s) would cost more than " + f"{args.max_months} months of the source plan:") + for region, currency, monthly, target, months in violations: + print(f" {region} {currency} {monthly:,.2f}/mo -> {target:,.2f} ({months:.1f}x)") + sys.exit(1) + + worst = max(rows, key=lambda r: r[4]) + print(f"worst ratio: {worst[0]} at {worst[4]:.1f} months " + f"(saving {100 - worst[4] / 12 * 100:.0f}% vs paying monthly)") + + if not args.apply: + print("\nDry run — nothing written. Re-run with --apply.") + return + + dst_plan[dst_kind["configs"]] = new_configs + if dst_kind.get("batch"): + call( + "POST", + f"/applications/{args.package}/{dst_kind['path']}:batchUpdate", + tok, + { + "requests": [{ + "oneTimeProduct": dst, + "updateMask": dst_kind["plans"], + "regionsVersion": {"version": args.regions_version}, + }] + }, + ) + else: + call( + "PATCH", + f"/applications/{args.package}/{dst_kind['path']}/{args.target}" + f"?updateMask={dst_kind['plans']}®ionsVersion.version={args.regions_version}", + tok, + dst, + ) + print(f"\nApplied to {args.target}/{args.target_plan}.") + + +if __name__ == "__main__": + main()