From 2df197004608810190f788c2caef31acb04f2f04 Mon Sep 17 00:00:00 2001 From: kunchok tashi Date: Sun, 20 Sep 2026 16:50:54 -0400 Subject: [PATCH 1/2] fix: issesu --- AGENTS.md | 191 ++++++++ CLAUDE.md | 20 +- api-backend/firebase.json | 5 +- api-backend/firestore.rules | 51 ++ api-backend/package.json | 2 + api-backend/src/index.ts | 442 ++++++++++-------- app/build.gradle | 12 +- app/src/main/AndroidManifest.xml | 5 + .../main/assets/fonts/jomolhari_regular.ttf | 0 .../main/assets/fonts/qomolangma-tsutong.ttf | Bin 308940 -> 0 bytes .../tibetankeyboard/FontsOverride.kt | 37 +- .../tibetankeyboard/analytics/AppAnalytics.kt | 70 ++- .../tibetankeyboard/app/TibetanKeyboardApp.kt | 23 +- .../tibetankeyboard/auth/AuthManager.kt | 7 +- .../tibetankeyboard/auth/UnlockRouter.kt | 15 +- .../data/repository/PremiumFeatureManager.kt | 153 ------ .../data/repository/RevenueCatManager.kt | 220 +++++++-- .../data/repository/UserRepository.kt | 52 +-- .../service/TibetanKeyboard.kt | 29 +- .../tibetankeyboard/ui/home/HomeActivity.kt | 18 +- .../tibetankeyboard/ui/home/HomeScreen.kt | 58 ++- .../tibetankeyboard/ui/home/HomeViewModel.kt | 16 + .../ui/settings/SettingsActivity.kt | 33 +- .../ui/settings/SettingsPrefs.kt | 11 + .../ui/settings/SettingsScreen.kt | 36 +- .../ui/settings/SettingsViewModel.kt | 16 +- .../ManageSubscriptionActivity.kt | 50 ++ .../ui/subscription/PremiumActivity.kt | 109 ++++- .../ui/subscription/PremiumScreen.kt | 162 +++++-- .../ui/subscription/PremiumViewModel.kt | 74 ++- .../tibetankeyboard/util/TKExtension.kt | 2 +- app/src/main/res/values/strings.xml | 29 +- .../tibetankeyboard/FontsOverrideTest.kt | 17 + .../tibetankeyboard/auth/UnlockRouterTest.kt | 9 +- .../ui/settings/SettingsPrefsTest.kt | 48 ++ tools/firestore_rules_test.py | 135 ++++++ tools/play_pricing.py | 303 ++++++++++++ 37 files changed, 1832 insertions(+), 628 deletions(-) create mode 100644 AGENTS.md create mode 100644 api-backend/firestore.rules delete mode 100644 app/src/main/assets/fonts/jomolhari_regular.ttf delete mode 100644 app/src/main/assets/fonts/qomolangma-tsutong.ttf delete mode 100644 app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/PremiumFeatureManager.kt create mode 100644 app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/ManageSubscriptionActivity.kt create mode 100644 app/src/test/java/com/kharagedition/tibetankeyboard/FontsOverrideTest.kt create mode 100644 app/src/test/java/com/kharagedition/tibetankeyboard/ui/settings/SettingsPrefsTest.kt create mode 100644 tools/firestore_rules_test.py create mode 100644 tools/play_pricing.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5245f16 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,191 @@ +# 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, PremiumFeatureManager, +│ │ # 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: 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). + +### 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** 23 · **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..0492007 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -129,8 +129,24 @@ 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/`) 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..e671733 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -22,7 +22,9 @@ 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 @@ -31,6 +33,10 @@ android { 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 +95,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 +112,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" /> + + uDac&hSW(unI{73is*Vu_wv-i@l$v)Z&L%}?5U8FjtE+9C^u>mckv>5b z{8(Az*uo0c=W|K1uA3-fd~IWV;_6&iPQ(s_zGLd#$=zREd(?v{pqGfBow}q)=NF)g zCJK|w&&=rVoV(=pV#o@fM&w}VoV=);ByxoOLnzmD&RITVjrz>bM7atQFV5+jK6zUI zjN3l~|7?_}b|Hh`O>Ri{ARX2aGpbmGLFPW=*J(JGk@x2 zr%N+;5QXnT`JB0vmvyT%RKG)gq)9h#^4#g$b3eRHqR)lM=}`ClMLo{FypM$SC{gvr z?uFC4i=Vpy{)Y9a6GyV`fcH}7j+^A3-B13iDafIB@4r+kho9G0KK4`9l_}0DM+1_M zfLz1=Tb+F60U~wvPmR}IomC_G_CooWLx7y)5Wv*rLUYK0IB6)JT2Xb6gMkD}Isbxf zBPFTDTCo;YdWS=mn&z=eCt{4rz)vsP>1DS0@~@(%h9W8^ojKI5!y$FDdIqxXnu%zI z1#umb(!`!ZjFjaldI#2Pr}z0;S|T=*1WZ!?h4PiXRLIJxoVQUW5HB`B3oA*WpCae6 zW7Nwo69;`E%05FF2~;8;1T+IRKoRohAuPAkpQ9-<4>1JDp@Hfh*3md+5Vfw< zDpUp|Y@l%Q69prlFQ=iOi65o#kurlrC50-3Z0F>1aRG6Ja((d+T7bG~;O7XqJ3LE% z2whR`si-F(@Y%_(z`AYFxdHMw*nURdOc726Vxw(Pgi$VZ%|TcMno`750T;A0gR;K(8e6_ae?(O8pUJe4?Se8tz!_~dh#54%X46(NogNaO zlP4cfFWPCtC`+ZK6QCOptWrIdx2-5Qq++dmwBq5$xi z4mnw9W5j4*g69Gt)h=7sWg~6k6DSF7N&&RW$&|zTZ3D>9KtJf@wA~h;qu;(I736)Q z(9%+oZ0qA~>}Aw>f+vu_oj#0eAy01Gzrdl=_JiDqa{t{7@H5?vk-iI&`)vfR2wTCk z0Fe9c1|T!k-am35{S(mV1@=Bef609|3VVOq`wV?|1?|7lS8_XV!ndSFS`Wzm^$X|D8?dxEJgDYl$B7l;vJ;lwa4XjGPfdbrDWwL z)cM4Yt*AYx|Km4E>4tPy;WZNM*|~P|v71 z4qU1DxSAZR8-k|#Twf&gm3V0csLrIsJPYEkMN4(Ve%!iRjK9>15_g1c12M#beiwC z+4xi&dy+NwNeD%arGa(8vQjL^Y(ReP-Idzf0tsBHt{-IOmy7=ZE7RBpeBcb zW2ClXEgpCddA~vW5UH+|k?Qi(M3)zks=t%egBo5&capPhD`@T_hrzq;pn-k7nWBh> ziji+Y`*jL|`T7x6i@#DW@xe{N*CYn-0FN)whi@VMAUO{1K=?Q0e+yj0TBt?%7O97_ zkT3VO2ZrBJRwA4S*$05X5x4F}-c!i?06ZoW zAFvR=hJFJNho2hBd1w*p&H~ML`b^)^c7*xNYNl1aExiBMC!c$a1=$B}wpt|(^jkrAz{UP_se+cvwjX>_R(YQUddc&5GJM;ySJw9P1 zy_X-v*!-TNM7phC{0Zw?7nLa__}^6PgJL2%@(!Dqn1=EFEXLj}*y1$oveGulvut0( zRwj(MXi<+jn2vs5iLq(LnEaEi59t`Ovr+Qpr3fm+A6-VE*1v|NxU<-LTtDeCAM9I->@o0)W8z4+u9mSFP`_74HFGusTzP}^=G~x|H)88sS zxV%)T`(cm%$Ar26ZLz!mtjO;_#P8|*o# zypKQ7_YL%UP=tfup36DHzyCSm+y8~AxXk!_moJJ`N1om);dSF1tgWwFvQ2|2na$--}zX&EZR~>ACsR z!@T)g1mDo75^jB8gSQZweFsHpU%&XG|6bAF_ZD=mM7>AE^^(zul+TAb)<;0ogVeo|B;{OF{6I^=geRE={fM}|DG=G|Eh~^JO96|3+u3&#T@Hq zMWt<}@E!;j2)T&rwyUwQ%y(e0&{mgo~fL9Ma#2>Lv} z<=`tk^7^xU|G*@!A9#^nyPnQw54_DI2fpIZ4KM{C=u<=tw(;ESzY&cC?~8{Ai^cte zQ9S+n3dNGavwZsXC&l=I&-sq)ud@r+t|%@JJticYAs)uwbhwXah&k9>sN{8ux>HAG@($%6+__s`xQ#kPbxp1E3J;cF?|x_>@yNbn1JZv4ozG4Cig$@$ z{Q-Qo^GAlJp)8s-gE(0a{s7EE{z~kzAmf5WPS(3n{@-55|AT(k{#)||bLiimCof@6 z{eR9`+eh%v|Ig>FkgN-_FE}mlOa6EKB7bJ+xFXr+!*3h9Bp$GZ$$nCqfpy})@RN#6 z+ur}kPm2B5-w?9~GO#~d@^AUfw+3Fq{_1tM@A^d9fASLpDcC!`!OO1iMfim5Pn8)% zPXCoZRb&o+%y$eH$^I1ks^4Nyb=&`;U;X#S%l{w0`ak34zu9;h==taI0v{Ly-}oYY z=?cV;BEB5wswD^?gunCyqJalVIkXz(wd6FUMA{X8c`s0nya3Ry$2qXj?w|Q!6vB9% zFaL`C3jqB6;BOJ02IinHZW{-Wz)zn?d@7KF{3D<@Ap8n=9Q38&I~(x@&?Ot;d&s*8 zTAVYHFNQKuUXJ_$TfBDbg>2PC|SN_>@5| z&c0VaM7$H2iTtmS2blwEoWWt6fk<*1_!(gUISy^QIJ+-LXhkU3`a~;l>O!>rB+@ef5OTVD81Vxnu3SMH{e4YB{RY(g2>c#^?y`;l zXT1E+c=@--3!h@e-3z{LF#IOMy^i>sTv z#>3M~>+R#~=N}Ll6dV$&lk|qL@Q6rbRCG+NDK6fekeHO5lA4yDk(rg9lbe@cP*{Yo zTV>@Hl~vVaYHI818ycI&j%#jd9pBbIVdA8Y$+Y9%`|jWK#M93nc+T?t!53dT^ztjO z9)9gNN8Wh-&9{!ebL`#sX56uC`KtLFpIO_xaQ$l9xes@4v~q)8%iFfl3vX{-x`w6_ z-G0~fCEK>|oz>I5VD7xxbAC(57t`-%E+XpOG-X=XJrCUd;I4-req{G!kJF?3pL&vR zxpf^_Y?j$5*!%chhp#qDeEqqFo}f4AWBMcgL>Fj~Y^;$@X3N+u>>mCuKP`^9N4b}| z&vM`7exLgh_Y>|P>s)nOU4Sk`r`JX3%(_h70ZFg-4?kh{Fo)V~;H;xPw2$7Tlk_KW zyhuZ=fpvi6>Kh!%J=(p(eS!Nn_xs)707n;{m(EWYq|@0sX58SYHfwD*+jlm26WdAP z1c05o{9_#gJ{dYZ^!VV17cXDD0$jLw{^Ac8|9@<>q8(WoD$OrKZHknPQC*;b8`SsGrtDJ04S;iY+mecIYe-9Tr7| zp}gEArwx;lG5My94vP+1rN5@J=;V5nQS>Xyd{EB#C(8U`%KRIYOry)DY*VbR#Gtc$ zSZvT8X5;Hy5Z_X4Xw_MMw8zKTS?oh{>q5QIu@lc&Ei9#u8;H zwnVKs>jwi)x5OHXODr)4C|1>QLjbcV!!-uo#(skGh9A#E>zi^WkK{OnYx+qpl$&_N zOcuntLoW6IPnIh;IwqoEirge|G5#gbo) zNdDvzW0o8=$Af-y2O2R`HbZ@jCEn0&(HaVet&I#Y2``!1*kZ3ToLgei7FqCdf25Wr zz65nKL`pWw9V06!^D@-8yh=&7GY6A(fiEWE%T}wbhsCD|of1*9v1Qr}OK3;nG>p#~ zx|Tr6lHUsRwHjKcx5~X_&{(3*KpcM1f%aNx+dol{5~JA`Rk&K$!UILC+*`=dmExD7 zFdGFLi^FhI?$N?*T?-4OQ8Gvwp^#(0;)8?;FDgf6gs56n9w=dO+kgHGl>&zw05vS? z8#*bVgz^`ihUNUHstr@i3PtHkrWfC&;IEh>VOMa3+kdPlmu)d(45*-%yR%$wgDI9H zu0yff!qHB9mfVYeIt$h5S`5<-tp<#t{JIvob7WiF`?spmP*p#^#opT^qonCqsp0bU z8)YLg3opU|ERC^us@$jcbeTPUBU%1W#TBE)x{Yc>RpUlkvSEZF={8na2tzg>(>2{Q zc~~GTg{L%>PB!Q?y3&o458HaDY&@8szp=Zdqbn2hV56a8+D1cTOEx;wKJixvu8_rh zQWdLeDm2By`4k>Bu=Vu^^V#~w@hz`vuu!gVYB|VRVJj%%*kAy7!4lmx**L3OyEb;T z%0rucU`YI77G}sL3peB*WZc2U;$)a!XmK_a%K3S6e%^4tgPgB26k3=MGf}=_W|uy+ zt1u{ZT2XQ6*urU{O-0i}8wOu`W5S)EkJ z8qm`iHl{ElG`>2oy0%(WBdV)px(agzFE0uUEi0WCT3QqlT7rBkW*3TCF=9V?unrIQ zrpLz~-0YF>!Mim~nXMk{ob5DLg^*>_ShMR)I@Ay(PR+{+(Yx-r}&{LSx6bwH##ZuGV#1woqYkl_j{b#j+>3waU_q z2n8SXp~BXfm_>_X@Uv*qqMjZW)3Z2+kYdPO9J3fvCYQy?nN0rWhYjHjd^6sHJtNsJ zh*xbtBK*Pnjctg#v)B0^tlMw17x_u?vEnt`7dFe_nZehw5tv7RVX@YCtl!DsK>siN zETbgsCLW+{GGm=zgMEcRR{YfzM`N+|Yq!T^Y5IRD$wbR&JXJwA`S%<6orXQ%=ua#n zH=`Hn(?ksWB94)XR{=PLVXKIj1B&&)8NApT#LFSnaaaXlujFtH0G+A>ID$7vpjDs4 zbEGg}Ki)Zkm(yAzXGnFvK;)tZbO7>QdVoDdt}R4vhlt$G0P1SG0Lbz{Jb@^IS!o1 zQ#901gS@o$MCnEVJkxuD-N0d@j2!^NOvuRU8o^Pb>{UcL%kaJ}2sllYH;6}C>_GH-qI+BL}qNCuVxM}RX#s6rj-KsT@(0DZ?*qRB>JJ#da_3gT0y z0VtmW-c$1d3(+(ku#ISX7l8cESfZ{y1m`h~SwTdzJBa2WoCBWoKsRp}uphWUG+zx= z1AB?OwL}Y{+amB-bcU$s2+f@%jX*wd9>=P7oL60l zRv~}YexlV)0NQ-ZQKDN>)~f*46Wz80hgjsV?IBtRes`em`fQ>NpAl_@j+;?$%O0Y; zw19SDH|js+2do8-6Fm$b4}<0r@OT6?k3iO*9suE^2pZ3%SqjAbS1? z(ZO!uJkblw0O;}}>b(eE4x!#5)H{TFFCqRC%3nH6^l~w<2LSCWXwxgZi4JE2r-@$e z0ZtLU2L8Vxpd2_xbOh-m=ZIck3tT07qa8R-^kyA^dPhNb^bmluw^kAT7Il9MJ${S2 zZ)*U^JBIKW!goOX4tTwb_`7HDdKEI?gZ{^-0igd~EO3D6ebBw%NAv+?e_$axf$&2= z0A(MA0lkDnG~xIRECVhOeR6{6)6GP`KSlJ}aiTAge+v42)k5?&^!WxdP9yIRsPspq z|CEeVKj_bVM)c<%qHiJdJMjB%4$)Z`;0)3CyNLdR{J+KmeMINFiT4Q+OG;mY8B1F{Ktb zjw`GtygtKHt=dIQ-9gOJ1vo&=sSfyzm~#&?7X<)5t~-diB?IS)x#QKE`yesRHew#> zzy)HShe+B_T|PZmC2p4KmV$0PxNjB$f$YS?zeT?LsUE`s6^j+#SU7RuRi@Ay$wMTqRa` zgji88F|2K@WE!zj(3T!0R@OwUTnC&bR)O-$d;l`5pi6ZJu`zYTYQU!!eCo=H)n^lH zkTFQC5qdR2)>xE{Bj6OV=3T^El7aKYT2B%if0|euc(sFWLN~FA#l+zAad!crZ1N#u zQq4EG>BMF!fD6QCA0{?u7qPkEHxK&FZztA`ay$iN z3r-PR2;CPQ#glc&T)dyylJ&%v&LOs}j@WX@U4c3)QD+sf8tu6yme{Q*>opR)%@2T1 zx1-(~)LRP~>k!_7I(M3ht%sZq#l$uu+ytJRR}tINMC`6<#I}|b+txztZp82DBDQ@k zu^qdK!56cgCy2rSviqxvJpf)0f^IkJKD3wE!zg>?Jh4Yzh&`qz_BiD1g`Q6+fHTDQ zZ6o&NGGhDZ5PPbJ*wctV1DOYOz*S<;ULf||abnN!A!ex~b}$=2{tKTGdvPZU&06Kiw0t^!SXc@7Oq4UR(aq=j! zPr&cfI%2=q0HFU2JU{mXb`$%en%I|G;2g12J;c6(+^o!e*wS0<^!jQo!dd|Z$ZF$Vt+qC z><7sGp^w;kX8L>Vyv3|7cau{%$*p=hN zuI?py`p^tgVQ*Z}AU6~Gx{L#S^BZ|iW&m*`%8Za@>?0lpeWUgek4C*{@QOY_JjMus zE_NDVA#Orl6X;E+h{rVnr-{d#fxX1dYG4ikx&-h|K%In>#1oO9*a7S(o&?@W$WKCk zau@Lw1<(zkPHHi5ggDj%p0*4)Lp(hnfF2nd0QE9Zo@oY-5zj)H1s+)^h-V|7jd=DU z;yGc!4gk94wgBLhmkdCDz6;O|fUdv@piaSO#0!yM2>ykTRRsA($S*>E@f_kMYM=)| zozimPDDko&U=?tdczH2kAzq;cP!DG$UYQIWCtiiH3OuS#60b(Q8u99L#K&wVUIU$L z4iK;HAYP{f5U+>6^?k$}P~J$uUgAxV)dZenV*!NY@_|#tn?c(=NWA3)@z(vs$JY^W z18p11+Se1GfcS*N#3$;2Bg7|l0N~xRiuhy~06I@G0>_ABt>RNZBR&mf)1lXNl+6IY z8E1%h&H+vn??PD@bf4J^Ab(aDfc)9r0P^P`e=ck>PYa;_eB^hV0q|YW4nX!o=)4fL zi$L3>0k#oe4En`~h%c!lzElAmC%z2y%hBEyYG6O{l}*5T;;WVsUyZ!g(D{~p0D9bt zvR>5d-9h}eYTzXC+o9{4F5+uDh_4GGJkkSTmpk_o$6180ze;>VFY%4(0OFf=5#O8- zoFTpi^6rBCt>C%ULVO$axEt}i&k?_8J@M_3u^n_fAmd&&aDw>G9^&`ufIY-_O#}Lf z-w&P-n1NHoA3RQc_c7uRq0Yly#2+apzDEmeBmQU$fbz%I6Mr1#dyN3v@Wfi;`%H{f+d2OJ^(dOLu2 zzk#wh!T(K^9W4h=5r1nM@EP&nqU^V4h`+rIK>o1~0Qv890my$B`R`>DKkfpc{_l|g zz7YW551IhTK2ZmN_CwHqqz2X#{}}WiA0U3R92g}2$x-5;g8uhm#6Kfo5An~dfwRQF z=qCOp^1g)5r_urF@fFIxLcOmy6aS_dI8OXDbp1mI@jtc@|C1KDO8gA$^5@;ezfA@% z5dUr&@v~+C@$a`0|4TY>iuhk4?_54`j`-hp5dXUdK>UXj#Lv$m{v+!Ac#QZz!2ciT ziT`9FexZf$06CtZ@lpW0NN9Bc=)7A1@bUpK zUl#!N{K5ddFY!m2|5XwJ&?Qg{tR)eIvLMI{HUgVTgdjfz<)J~qE)uxM6S_VU(lHWx z$TJ|{aGFF|FNtt9u!ls10@y(!68c3RAz{o1Kp!=YL^J`1NW?575vu{tkT8KJuAM|Y z>c^wL88XaANhGWSpkE^7CPDWk&?Q%sNCA(OwIotOmli}K9Xh6;B#{BWnb0|_1-L*W z`v8fYWZ)c$+`T07ih=Va@JW)))T=&2VoVQ-8b1=X3Sf{#orOd_bZ!8zM$j}KCeZ{QO^`iyKZ$X3 zNHl8!9_!a>5+m?}Nj|D(K0r81pz)=#DP(JAdi4O4XK$|9S2CkBrvYy0L zHL#Zi&N^b+aT3#ak(dF!I)ebvc7gxQX#nVF?EoNWHfZK#11Cw$Z2>?xZySmE;58q- zx{CqS!@40BY5rsC_>TiII4OdBQJVas>^xFivo1yz=&}~^u;x6#G>j;Uhpxf3&;%?}8j~W2q?a+D0 zHWK$5fwLrb&LM#{MBI0p#I98&?hgY%^T1{j4}#{w6C`#o1Hk*C<0Ky5PXgRDyl7Vw1o`%lPfY$-g9JoN@ zS@3ucvY$Ic;`zNKEY$$=4mO7!v8s)YR8!Jv;o4$GBlxX6r- zNmq0jGc%&1GP4+-sQZRFrxg_zX(I>sWJW|~W=0v|cWDU^;Byoq;28j(A|i*~U?~Zi z+X#PVE#e7L5lNnj*6j&eb7VxaZ#)lR^PHT0(vy;6#*DKLG3DII^rVz%U9WY(uCHYK zQOtzCffP#Q>t#quvLi`IUP&U!+hB*mOA=m^mo%e(=My>KUaG6CV^QNO$KJO$_uGpN z<#pE28#`ERzV%Ztw!NI0OW8fvc`{0^C(EsK*>>O?hKnPD1t;0=6LHE^d<9CQd?K%; z5a#O<5s6mh^0bsByi$@g3~?;d0}&rzts7T)8&cv#q%s$#jN|dl!;N`sL--`4)0%b1 z3(QfqORCb#6Wtms!(5iH>b^^t%i_o1)|ehsT~!^+7Iw~Zj%_SUAK&B=Rmdt<-0I2| zD>{lgQm2ebQa3axTok$h>($L`T&#IDi^8ksWY2HTP%T`@95w!)&YnvfgOk(C%|Ei; z&TXZ-tjuh*K}q?xua)~0Uyv6?QVJDO8G0o#gnL6*gFcSSP5GC!-VNnGNr|Z`$r0@4 zgkT27in1r>OHn0l$%zxnqfM3VVn_Of;)tlyw#3v4rIDtxw!!y&VnV$Vb5ruOMS_1+ zh$bf~p(xvZaA&QUrT8MPZAtE!rHwJMjZ3TRmN!NZZAfZfQdG07Av$5);?i1#yv#Re z*0?o0Hr=uLuAz+~g>$Pn^{(Btc>~`!pim5=UkYtMC@U48Pysd2WVA=}c2KzqRS1L1 zX})d@!#2`Q3^y+=m!-uCxtU2L%?6#X+_FfF_sEDy2f2Te5_7qbGY$6UhH$Z)^;Ac? z<Q?0EHhN0Z-5nM8wxmyLYfr9R++eDS*gD5M$U~LTxFA2dIWH`@WPU?NPws=p z=-*}4`^2VwuFD*o6qJ-v)c#FPmN7FztI`AuXGt0CGO2fSdV6J2f&Z$dwG$HxqdcN3 zCZ*1vl{z=mT%MC2h~`E~nYAW!T~%eww3i!Sxm};59Qt!zPk8yfl~2bt7aAS?X0J@HO=Z10m%S3i#DzU}v%Y7GafvB!Bh4uT67OnrQ`~9hHQA)J;le@ zhx?WmW*=OXRF`GaMEUzmhSgKQzGFSttQ}f3>!D0tWMx%@x#!Xy3qI?d_e^^D1@d$2f;PKLCRgnjN}Ovod~?%pt! zk%Uqs%zS;=OTt5|iPZ&0xp{f;&5S(b-U%ouA;FE~OPmxJCuI2I*r04e8t~ zG}_NK$j9By-Cb~nNQnrJl7ieqqcxGG=|QOh35B_(dbKeiBtJ1h1R1J}l3e1<=2)-H zlF~paI?U*+j0(^M2L!lkJY76h&c4AqkH83(ccdXYA|-uRxIRIxm3+O@(oK#ol8-)G z7v<+@^w)+4`MEe~T>M;J`~yNg{UXCM(qS8kCW@~We&kFP?jfZ}9!VaOx8%XTvX-;M zW$ZO;=|uKg1$)I>QDH3w{S;den@QK@KJrbmca1?m?DI!wi^G(*sV-^+2aga$CR-b6c?@!VPV4> zQ;3%W6^akZTmDANTRyYj#X{;>?d&DAV>~R@_;B`pCoT71r7v{|2*s>RiCl2VD7AO3v94v$0mokDI6U% zf!^V%ij1v;&M8?sR|j`VQoDFMYv=OyV_IUIeeE`i#G;c2-r?v!m6SqYXy@mkGK@CS zJ||U@EKTG!4P~?BwY`z~;}Tjn8$x|v+Eji^TXKGJ-m-0FEF-HSBU1ac!-O8zPt~6E(BhoV%Ba2F^NUtJKP$K1?3tifyJ~fTI%BX)sHr~J;OZ0X;gyzO zECm;JmKZ`)Dx)^EiBFcVF&4GPuiX^0XI|U=-MJn?l7~(bYK@zRrz)agYmZQ-Y)CRW&4Y2*vbhz4R|eshXEs=B_Uv<`qAxH1vgFEZf#?)t+u|+ zau$ey!Jm24&;h%hn=yX86faQ>YN{la>?lT8!;zL^K#ew(MS2OZQtN%SvtPQcV#1bv zmFZ&={EVS4US2`oQn5a1Vp(i>Nk>k??Tz0nUfTJXd;QV}TIcRxl%>^2`2<7=M-O*j z-{^`d>8YLV6@lJb1^c~S{~G8}44dmo9`fE;zRAxuBDZ4awV}TkS&muOfeN1U& zWxKBT)|~l`37MUn>&I`JoY=c8r)S>uIIF9BaAr$(L|M8Nn%SJ@B}JtwK9p2iLrQqX z^DA0YCf+`#>A{}Tmd%#t$C$Jry)wpQ=-#E3X?OP2gqL*{SKiT?qtsx7#i$7TtqjG> z!_~j^e;}Fa9d5)Ep3T& z4pfy$ne}FKO^H2hPJ7> z-Hi$6`dL}^4`t=vJuZDhp&>Z2C?aZ12+f?ap{8l`jI_y# z>sz)y$$r*E<~5lk%F?9JjJjm6e zBJWyUJoc`cSz)C!OKa|&n(pKkNO=8i_p3NU>&e4zd;I3hkZZ~2IJ?m}%;9p}8pDgdF8T=ahd(_Vj zTW<8+F}em}$PIhFo0bi>&A{E9)TWd;wMI&>ijK&Q@zHpBMg>P^#WpUktXVlO)-^Q6 zm{*U#P~aY&k%c8Di|I1j!u_Yro*I~vp4L{N*Gf^|Zn36F=g`=SLUVgrbZqsEbg7`U z##}KyG_>LIjOeJ0jHu`gG!c4_vz~HjQC8qB_$~0x4k`x;n@o9N+b3zFd=!A<(A1z$ zNlqKxeU5BOeDj!agO~A<2l^n5Yo%MyGx^z<@o)du<`x2#U?oE@Cqk~87r@^eStm^Sogx={O?!VP7`1woq7KuxYOWbF9* zEB!lmHZ-Mf-t+AE=UK_hjJ8~x&)wk3|s3(v}% zthMgx>?x46!4dumm5F6Xmd$yyvUY-@a8mY4>qn2&Jh<)Fgqo^VxBSH7j(esi7$Qq2 zrAxK-6#;3b(G5#sTkKhtYQ_5)n_(CelFIJ!e6grvXs{#}BJ6RN5)sdwnaH-98-g7} zQ%%-8Old~RkL?R#kNL;MrA1gH%96Q$$_FRe-A76dZhl%fAxWx$xXM)V^g{v_H*HrLl~*}8Ad>TpePgj(^5wd?SsrT2Vu@3OZyRVBvl^4N3l z_{aWq$Dy4&8yfSp@#rFXKFD@E1-k{v8YGR{Dl$U0l@4d&VGkd9lfhJ>aiQ@#4}X1e z96QIHw{B?~H}2_yeQ(XHnf`oldD(3TXVr9$7+`*|QOeETvhCG%e|&U&vwzj5cUP`@ zZ&PI;+Ks)DA_{UbEt!XXAA~g@lSY!aV)p$B+Kd|kyGHgM*|1J_BDRvLGRvYgT#_6- zB8xMv*Mz4r;)CR}B%@DCwxMfqTC_*F6sUCe)m24{-QKRDi8a}ZdDaN;=t7fqQgU&W zhqcF)9Hdrig1nO!T7NI8-TXAc&BA$6K$v8`R!|e|0v&@fupI``k2n6t(Ddsqbx_wXY|qSZe9) z&RE;y;O^}nT*f}}4T%nNNt&9}x%j@m=O&G5d3f!iu(Yhwq(l#mufVXns)x4+l( z^wzS@;?P3YX3CH})NXpcYj{bT_3of3mw@)Ye_Zp>JqIR?QMmYcX?ciA9&5}#)~>>y zHAqOqipnOlYYJ!aFIKgW1z(>LJCAJ3hljyv9k!U8hOHaJd9-JQ6yV^faP-oIo1%xT zF(L5=kD=e+uzqa0-bXll%3Z;6LoE|K@inYDI@Mq|xLV`q9yES%;N}isbFJG))3E{b zgfcc&xXe1H}$z) zhO?m}XYl&4czMp)e&8=FULovgyzFMP8*SvYWIsa7y({~&kx}pxXEBwMcc@!(SxYj8 zzJI39)!#qJLo4|ixwF>YXjJT!Bs6R2*u!k?3&9SUXX?;yf2%aGvWG^T=diuJ<&hB|>&MGe_eOle{d~gVdO!ybcZpB}F6ii}A z+=&g(W4l+pF}N82-kRVcnM0zjlcII;I*nQ?%E8}1Jj4#L#wf`gio;fXXq1&l>&%kI znkX&|{z<;SkOx?4me9oHoBX7~vEms?z6}|?FkI$rD9IAipav$eowHq33Y@Jlwgr?M zSv^OTG5oW(aRNTakCpI0dP?yjG3@c!P_xcmH~2RX-KdU|CLk;{JSZ?p@zUT*eki9* z?<8TFD9#>q74HrO4{P8Q1`P^z;eHxNHM#}s6MUWGOZYnZ-t%8>JblqGqnmVjHRty& z`sCq>6Zd?%eB~DpwYBZ}rn_y+x(eSO;wVYUs#ce9e#LHTz!^4angpCaXs;6fpHDFhRBlkR8tvGuqB)_;(J`WUQd(hWSva~wwuAMO;*?THI_$V9T)q}#zPRI)|HCz$ zeB(d5&sI4M-}MfkU1YO}khqSNl%++>wrp9sYJA@Gha6}ZT)!d z>Q8o#t=#;6?_FPSud?eW?<0=GW@dT!6~)-_?d|YlZXdQcO(%JmEnn)Xyx>S!;>~*t z#c}If)|ozGZZ3gt9#UMed1z>KlChUMHk(Yf+4`;bU5+g(u1Xqw*27;t?zU!}+b3nD zHt_`f#bjpPn_nJ=aeiaw;X*H|Vti8fqXz3!Sz}DzN?yi{WgQu~><;&Y>X?TMY(I!C z7-KjEF|6Msdoav({5N`if-?dx)l>$LBw6|44j(oDuj=D3ORYs!jNw56(<*4ryEn(e-|NS7Mr6Pj8cX&N7^j~iE;yrMd9 zQFCIPvnvaXMtg9=RrsMV&?olx3~yL&axV6xiNQnRr*-d|@sww%8LmPBR}t#5HLWo_ zOd%;O*6$Zi9GsP1YH-9XmHom_&$zmL{7pUZcf|ffjde)gyZy^LgwHDf@zq!U>smU$W2BON_XAbBid=9O|##xui5GzBsbxx9l%)wb0KU`YEBG4*JPAGPv=8 zgMy~=Y0TGAKHx`0rYMyWDN#C&b)sa=(@K%)(d_Rr$w7`$EP(|?XO=2nkyJs^S&@SZ zg_Aj3@2-#}g}b-E#x!xLkiXIy5oC6fAj{A8gTo_`CGQdK%LFsXcb)b*IlK$QDabbv zQ&ilX9KNMZad;#wF47oaeId;Hll7ux-L3U=@XAVIM||QEjpyIZa}N$r3}c^$CYg+X zIqT(JnGLPn;@YYQ)46SkiSN@2oD7mA#f0eQ4^84ZFG-1deBp=*k6kd-!*`}t`ou^h zcZ1kDDpeR0#lNiI!{4?dotgcRI(kkMBc{SJ7;4*>ZM*5Pk5uvR7)vkD&5jxxs)!RV zzFr>;diy$YbCI<JahLC(Q2F{0+6K$ z57A8;p0k*`Sf4DE3cJf|q=V|8*Ccx%j2Iougo6*7lPaIfMF)G_FL7~+6^oTw7hx4K z7+gS=5GXXt^z_1K6+G)MO1C)|{i-Y|$?r=OLT+6It zYi%gBUT{b#y*^&1lkeTH58u0cVJud#gJss*(Q9{<7g~Q}?<5`q+NfBy(!F!4Ec@;W7>X&|0-KrZ++kTK_gqO$QZhVtA?)OA2-H+;b-e9 z{L4&w`L3TyDUx_#utn^*o~o4fnaPi^O#=T)3ihq|)I0N?2_fP~>&KA6sQ~}-t4@^S zU$rg6y7tRy1s6)GX(K0eZ{Lwm*zyu5A4%n9#eN?1wtwdEknd}T_W@jySEDhf#FQil z$F`L(yxq%~7M4*cqV!UXam?JZiEDy23MY+=qsBYPXX^Oql=h`D3Euj+fTCsb87iNU z)G_9?=8BM%cwJ6|S(_c16crmE8D5cTNGpn5Z2hscO0U9^(I;_j_U$YsJkzHkQI~2& zH*?z{?iZ_IbAS77bn56wN^B2gZ-|XSWLlE{&G!y&ocSzYm$P78QgP3Hjhh}&x%ztq z>0K^GPM8{5mm3*TP#^1Uj7i{6Jf7CMqkZcBRi&Mxtf?(p?XH_Tw8|RTYHGf1Z1sxz zShY3+HqOPr^m|T}+oii0=ibRrGdbWKLUOSG1rhe&{4y>M*e?tRyv@le|%Cp6!vnustnn-UE}n_Abbb z8@IeFvAZictsZ?*iaoQJ;;*=gl6Q~tb;ga2JKj3{s$Xzemqp{06wH#m*%FVCD8F)R zaF{M8$n~Lz%+0H7^fiUCrXVM+Ux24RMIV*n;(U_-edv6CMVO;b=K)8{wfQN{+h(Tw z7}InCaY{{ye}WVn7xP9|zI-p1ih0>39;Fg$p)Tk%x>NKW{uCr%cE+%gYkYidw@)K^ zZ#lZ}86HPz!_~Zg^$jC3!b?ArVZS|N9#ypoW0#ipjD1Gy?Z}yec|^@jG&L@*2+0jG z1~?gOirSV0Hcqa(b#HUdEu969QbB55xMOfecw<82{Jgrw)#0AWEhX=y6^!?bD~K*? z_B9o$Xtj`{>ocTYDp<7(kNwY+fAxWoWcZkSeY z#`oITK#e{&(`sugzy+RXXmW0-uMUfmLKtVx$(m6aHGOXO^eXwYk^D_W+@koq{VoOP z)SLXvjXtx_UpKCjyYj|I!qHEJk!dUC_=P$@~u^BONXiV_M#8oPf^$@-3QugpGW;98t;v^X*p;f9hRB;w9^n zsDuEO(#_w?EzrxgPalz-907>Jp~acGfeM|@MV}SN(#N#Mx~LpNr?UOY5#cE*;So4* zz`rfFo?;!iHxH)-yUZJ(eeC0Ccnd%5>HbA}gSlyOX;hZc6aRw)jdzG|bciGj{GEQ2lY>eJjqG&c5h>_5+%=0G zumQexGLPZgh2b5ZJM*$X8jwvQ?{FgV2mt*iAD8T3cVQ#>$Yl4(Eb?P~sdNZ7u*u=s zNr}NHPsU_wv_YXpopp)9*Kl3wqH%VL)mx`VrGz*O;SlWW8LmO^4W52AKR;o^dSO+8>n=?1oJj)9IXa8S(tjW#il=KaFlGRJQM1 zK861BTMfHwyrJF5YQbtDz`=W9yH(90jp{qw9d%&GoLd|D3)$w@Q2J2-hbdly;fV+g91?*3jb z!5D%O{9aR{N*~@bHl-xoEu^$(ob~a@OnEXm>9P{pgv=_FhogUVthFU3!QWM$46gd@ zST-S}Do*Py89l9Ij7hMti)@a}c$QMvYI2r`u51syPY}1m9$1ms@M=72<&hg(tcUg) zEsv9tEZOqIO%1>6Hq)4Nol8)BxV2kjjPp@tl+5l_1*F7T=X=O+H_!%8`!tH+_dOF* zx}@22LGh-&1c6aeBuy$!e zVgucxit}=$%(-I|3m5Jj*I5{l;Iq&trpTPoTo@5q+>)45FZK-l6lD%_Rob0!W-@C} z$cxaVb>2O`^T3Ld4A-Fl#oK$p$yJs8<8yAAJH7Ybd)t|vnVs3)+1cKEOLjNe^iA)L zgj5m;A#@1RyA(l`CLo|7YD7dq6i`u65nf;QwNWHHcmL11v%3iq)c5!QeSU;w?wot) z-gBSxoaa2{`#eWcI*j&(+A6KVp@SoX`vyZ%;5%4Ph$-N^2=JYDRt*IGtIS$+EQsAj zSPTIVVtzy3sda{+O&N2@d>)&?P-lllF~KegB&=eJl*Z&%mEiAW!{d)D%y@lM8&q92 z>|ReksN-fz1GvmzIazW6t#)zX# zM3YSsPB-}-Nv}rY$b=3*66;=?@(m?q2BAh`wMvo={UKY@t5qci8;bogg{NU51Rt^y zfm|jg+t@$Uk0y0$t5;(TdA-`Y1(#Qi*Lfr|imyk`!lF96${97<6Y-?eUX`u&rG|@c zkyI=x`j4@nwa5B z4QiuT^0z>yvo>wtv{Y(KxW&k#^H!MrlZ_%<(oa^f+M%-Y`9QweBfR@0S_?8x*q41EDL`BzcqPt3B0+<3DDlAGP80(Tnr51uNohI&c z22FAX`UPtpxec%kwOqfj?x?eanK%FbcB@eII?jP&K>Iv4yDisP zge6;L4u+D>O)vT?yj~M~fip&Cb{)1`0|EA9mCc**+;@X39uAtwkW;T!hZ7SMqk5GI zoDT|DK6(xN%c^P{gB%W%K|Ao^gTVw}?}X$(LgLIPe*LRN&MJidia~ciKwgaX_}THo zE|D|+Wx#V%wsp9P4%4O%VXuQ2?NJB()^9&9GiQ-NZ`T`??Bj-p_AZYp;!swEL_WWR zGSxZr>@N%ruvwAQWp>xFQ|Oysx7GkLLxCX?oPIMuR%26G+!}9GED`0pPY^3IO(0#M zUv8iyALt0kY#eVwoI?S8lfXn}Hv>!$;^R`7FtZ!?k67&q;$EI71HeZ98YEsN^J4&$aqLq}haCAy%m(V%Z07zQO3nSDjy&Fy;@ zR>)*hb7e<7){!<#WwMHed)lX#z=5-2Swr2jMmL;ZLI)kalO!H-qfUr?wg`x{SA6U^T5K0rKhjM(6|Zuu0mq+c_^T1gBMAa*{8e= zxXZ=|heaT72#~IPPsHS^*|73N24Kqp(w%GfSKYX=bDh0$0h-AJnuAnFg1PRInu&s= zD($@_(-ob^@WIklpxwI0o%473bL&0G3W@)U_DivrQ z&gw$Zo2_>n^$wNI7qhC!W%=Fgi!U5|Y(q2?a7FAYwNb#AsM57Np8CaCev+qXp-f~M zpWIf^gGy}4=3@9siYtFYJBWP|o@lwk5_sofv z(VRo9k_dt-Z(8LD8LU+yldXCfZS87Si^L(RC#$jt!1oq0+494$d5y71%w#l3(>|@= zstMNZSn3j8EsYC@mHBp2$dB4dz3V_tMNC`E48)R?o$L&jL0%Kj)Z! zFIZIt5ZT=6EnKQaK2IFM@FZw`m;=xPbaoCxJNKRA>oN9sWP(od24NiCKI}E{!&sde z%M2JeIz5Q^a&E>fr^C3xRh%)vjVE3MK*WN(fj~cUk`k%dss-^aCOw@kRSJJaDj?@8 zBdJsaK?o$mL_Vl?S>%*REmJ56Z)YJyiqxWTDiugh>dktC8cDPcT}UaCvcG#CrFSg6 zr%&yV*M=yaQJwJmDoDOcucNd!vrWIbI$5s*#aUtV#67-d8>!Z-2!gEexsySmm?lxE zP~lVFd22uWZA@Fl+Y7Q+4)geC_yRq~E^1Iv!I}T5_W}0_oCNp9+}LAA25kpp9_9!E zre6wPoAbDrX~vp?A-tn&Iz>7AQ}1zKnkonokJrOXzT{9RV|`4I*rNb&+9y~ zm`4FvT?Yk>EQloi!8j5qePXR}pxYk;Krh7M#ISy0~I!TYcx= z;RJj(cAYm8ivk+4W!Gp0WV>`W!6(sv-Qi1)UUA~s(b6${hmIXNcEv?koH%sk zmJ1F*h6_#MTqnd^J98TSm8^jb9v%%Y^c0Eynq==sBgxlkbUV5Yz?1}Fz&_yXsb_gL z-0TLVQ6_yB(C92M6mWJ~-^gY#5mOx62$l~TTvVJ-W6szCrHXvz_NhUai_!+0Q@0Cr zLD!;eONH0a5cf>7gEhzZPe|6haB^(lHON5@?LCo4cL0hVM1gGyyB%;a3SE_J;OJy! zZ0y1XN9_t~3+o>oXD74GemT3nB4eZ|sZm?Ifqji)sG(bbbJ=endX=I`_TzkmM=27& zR^1-f2_>@DOJ6&_{nV8MBf))`7IzUX=&O_hFELPQvA_c}epA2%wt3MT8t5pL<;9oa-cOb9t)5#DFtxZO zuQ^Ni$9;`j39VGAXirw-3~8-ZVY4|mO0 za>!M~(ayPEgRuBir@fqdfmhOVP9S?vgGwa!E4&#cUV^fCu^B;c#c~dbMlAAa+*w$f zjJ6CcP5EI;?hY=jv>Rj@zZS1H@p@l$xGgVtnxo;U#bl6I`m{c~61wM)jkZD zh5jiB277);jD)egk) zNsgW~e?bZIdIY<l4N`Na4LT6|*{}tFX zuY%VNmzu$;4Wb*{Rb@Rstpr;Gf8~7QWjh#b<8%<|A@DwntOK1E)_Ns2j@{(Upa1(K zk55A}YQK}cH4xHJ?4K>{|Iliu$-(}`8_|D^mXgxOeF{?*KHx9PS zY*e15(P<|Vm}5S6xi4Z6BJj{jj1eb#r1TU~U`ISLBe-cG8Owui^2N@aWpxS!l6qwdEn@FXy$)E`Np=ak4JcAQ^GE7eV0(vn1ujQV zGv^_nOK5|Ysm$n{gN}2;SkbwN4cf^$o%zIR0un2dt?M0D$1NL2&$DlMpy%j{T=lAB z-IK>d>8(S>HQk}{wR!8%sxR&;z7^`OcC7(s;`wGiq4o6B z;HzvXWA^@caaKTf%q`3r`mAGe`~N!r%AN}3ashuXi#q&ORsKL#6?OmrG9rso_y#$D zAcy7-Fu%SCe(fvCXLxvqgNBSFM01_as?X{)vO0SSgsK5ZPA$Qz8)$5>9aoyv88j%@ z*w~~FP;P*lbJ`u;cgqYxnyGhT9)N)2>_k85R+qylAkp=Z>B?bXKtf?%4!&SDJd>r> zzPZ>KX;#$^ZER?5-&eSJumQ3|`gBs&`Nf0DI>h(Sbl_8zpS_e<-R-oCY-K>h5^x=1)`a0Np}891+AQK)5f)aF>ef^dow zLmLXN>S`=Z30?;BK9)e}5C?w4mulprOJ#{*$GFxhtY{hzI}`bV#@5Br{6L-Dt0#rR2Of}Q#&*`{ z2D6rsNv2baNh(v}%14Ye&5*xgFZOu@Q5PunM0ahZqpde#v5}(24ngbsb}wVjB`>W_ z*Yr??PHFL9wQlf8bis+`X?Ld1Vvh3lerG0O$S-cyb@a3-OC@8(h zzQJ>G9(wQ*0S_~J2EQ+?vEZzOm(2RJoNHaj%sSRMmnqCx;@Jb02gk<`Rvy^7+uzfa zS07B`ugQb@LSwVNaM5=fwvSdO7i@1R?p#n=F}jl+-zIOY93@4jG5`Bz~jMC-|7|}(= zu;SUL)eIz=p$bRXX;nOfM%c^vXm@dGH0x9g)EI zJ1bYQd!n4ck;!sRIMM?*;1nX%Q=RlTji(a>HBP73?vqqT9J!D|3wgZV8`_+Ur(o$7 z12R9I=&N;wET({5WAm%5xv+WRqOR3-mc@sT>MDA3wv7ImBHB~q&6CB3wN#9ywS~C-k0cW<&?~H~#x*D0nq=dDk(%i7A zHM?|lBsOrfvwyq0p`krwi0B44Tk;LvaZB0Hog)53gvf^=hM@-!$5}$?Y^(C>If=M* zRw9xQ&B?X7ParPrIs81~C*R}pgaCWtXWVbyaj*F=FPX^q*q=Z142%cjX^3AC?c{eL zZ-b|v%}~UX9XvfLE3$?gO;|)|eoBdLgR>ilN;x+D;)mE25AyVj73gYea zHZV%7J|VOu{P_jd)>e&HOwfh|tR=O8$?{OI!2F7ES}r6rV7m=KMcAo}ag60GhjSsF zcEkblW=A`#=jP#D2)!(lh_Bps?X@GYpTo({8wc;Y=Gwu*p`q(e-m>d;BnvkNuQVxjI7JNCIINusJ zbnyukl^R^SA*&9|NWe5SclksJ@j>|&2VDV^TB>&mHupe&zQ)+NYckUsR7PFC26BBx ztxM7y@Y?i-m`7_**1MiFnFe?i=d?He_iBTA=(e7a@&IB_R? zJ#z4-;wM8M{}>ClyZXD;IAw$BF^3?>}XQr|s$XV8axYbI@2-Lc)qx zvHv62N*SwFV2%gS4}CGSfPHAR!(E7(K==VpihPZ{0rLCj^lA<#MZN|&DS|gj&`_Yu z`ya*yS5>X-2t_)US5+-)1(dXjBLPjU(8=&8l+Xv=KQ`Q-qV zg?Hh-0=TXZ%D6794Wkd(Pizwy*@dtiL%dz!!#PD>jPVL;!2e)J3?!(`B7x?@IeZ=!`uTj7J$0vOLKRzLEUMIyK%H@U=|lGmD#idvw9PCswcV=Df*9(vn%$9`Q9V z&eW`I3j|vxw^Xj`P4tXbuIR1kg?BhdPH+|w*mrp3bF+5PlS$AG?oZC!L+JKdyNKZR zf?xg((6ghQ9i0LXBqWBE9RPR|pD6$_XC7fsUgqq@4;}STYGqsd&T(IDSg+D5Lw0XX zWN3Fs=kAf1%3mEyD=_oJERk}?%J67^j~q}CHbS;ShTJfiE=&|V&=bjJU+&qxAVGy7yl_^uI$Ky zVJ)ZiWx;MZCr1>&18AsR%-V^Ztg}0;hCt5Lp?SYo~uT-@Xs!i3GGm zyqbt{JPC*b%#$;J;dt-d_G7%t`S#AUuHVn|aQDa8_W^?2hmi?zpM5+Zu>kf6#@vccn8oBu?wDSn}IS2Tzm4DpKx>GZSMYDK2EuvyZ#tj@R{xV(LV0E3wZ+S&*kfl zR_^}Wxce`hfyyA<`zDP_+#j@yz~Q}E^)^`JUc9LT!WI~;3<~VgpEbag9I#an z0u#GC0nd-u6>JB@2=c@2l{*&&LvP-FD!O#ZxOYo>$HR+n|LJ+(A6W6s>Z`{?^~B-Q zo!JuueV4DPrKAR`?+V{FQ!Az}VPAdb3doqUod{m}IIna&n^ET@yBt4npZIR1lYMx?w3K z&#x^E><+cC?{=P8SEmc4tzN&+p>#6}E^IG}vzNZXAA_dK%uL zsZ0xq0{n2k9n+}XevEf7!Za#hfA@U*e%?;rv-9l_%(oxl4e@?A-`+mozK^#J_IIIX z@ADT}O}b%+7C^S1jU9)oUb8FTzebLe=R_jZO00f^V?9aE`YJf{@@!D~>IeTVrhKET z5Ho{-b$e=KCR42Ts)=2VMh;|-BK5_nE?`v_&w`tG_x^jdIrZF``xV@r1bz+!&;dGw z0qE3NAr`4q+WgvR9XE&mNF4?rWC!Tmt6&ZRwwQYO zI7^SChQM=>I#Y|z0PG8dngdJoiQ|O3W+>sQtION0(#+t-#$X{=2*%o*+QQ-1v`rCO zv2uK{rMjkpuQF-0HjU8Vy{ToxCUs&<7xr3~ZV<}FOzWbiv<^^wYF99GHR>V9T3eQ* zX}!`kc7BgX7U-^1S8p6i3#{1+ciN+LWIICsx_o;qWF467t#*g2>|%w%CU>{zVBsso z!o%nHu@7i6g?ej`G2$MNyYpe4K;uUDZCSaYJa-H1T8xtiegYgE%-s__>C7hJGY%(C z{TABWX8E|6_tAX&YxC{E$GrFE+wt08z8>>2Ui-`UIWpgl`4#qfoyF|_05H2a`oRH> z&bDPZ92f}(I_j1GGgkMvIAh}TaJrX3CdGete@F&Vf9CzKU{Bi@|EmXJg`U9JHpEij zgLiQJ%Xa`L1Aa`*$?gJ|(izC{uDkBF{PjiC=RW8n&LdIYbMzn7%^bcN9{hRK`W(jy z>Xq5KM<>jDT58ltEh^aur-D`3E0RS^IUjIU)#r9%yebgLW&_}2p8YKI4e0sgVd^Fr z*M5#a4uBN;DU3JHedwYdhxUP4zRB>uH{XtBd$}F+%{%k$Uz%^neDlJ5`^owC1H8w1 zKbmjH@~?cKviu_$g&)Cr$XM>L8) zY5zma;fUeCgeU5NCin}l$#c#8n4BUv14bo$mKU@v9k2_u3=0AofmPJ<(mU_!)f1<7 z26s=^ufMX-Q>?Ca?h5VGxeU^V^_TUu@1WS5453)k9&S(Ds$&cCp^l8hP5kMw@1mVI zFTG=DgV^A=9P^zpw{&&87T>b1KK=^3HJEiu?OnSUZg{Y#;09dln*H3m!{Pa;=YcK@ z%5K zP}$ek7O}*K$CuYkT(iDGv_R8ZX}ga+ zdTNv%%c_m-$6naq6qYHQH$Qo_y@&l6S!%c44ND-Nqg*a4zY11GzyqN(*{tAi!mxO7 z1>%^IWUtYFA)=8{?cP>WL!`ueAHTDRK%by91J$@VQ8X;FMSul3Ec-;p{e zy|u;guku?1lq;4^N2H%hacxANuYVoh70{xTgI@u20b)0wkzwa8_x|$}?a=?+b&nY& z(q|>yEkJWzfje&*%uyMWtk1_=Whj2O9B zM(?$^qZOq?!~yc1(k9|c(0~6`{XrFa29+Qu(FG#`wek$m+x<;i8+c|!q z&)bnrc5D37O=EGWF6L_A)^}&1!lo1wO&1wkwk;dhXHD3cujtH@k961QyfH&{k6|bsg#f(GC^t$KQjI;V^W{8BgU**UhpT!p z4MDJpw<;Jy z0Qe2L3hnXWu1v2$diKA78*qO@&>t7r)LxWjn`U%-gDxJ*ohVIaHLh zx!Sk)eK8QXD}}dBf4h&YnO^Z3o!)%R)UtKykRQ0Aa&)ORUSsq20XI+*wVYc1`R@Q@ zLqT1ZIeJ!u%?44qv*-!QIA!$78K2*$mt0ss#HzluxnWWlz12 zzoytzs4B^xV!xY0IrbE%Cji6KBxjRj&?dvfzB?1Vs{<6B`{7+HxC}CE@7Tcf1E*vd zmdN>(O^C?D^nz9KtU5*3vS$lhm{{vU+ZvjdIvY0iNAJ7Ge!L*JSNc3Ga`_8NrBDoL zD>UPhd)#(`G%$L8x7%LT7Eo7*$XofeB;B{R?#NfPR3_gVkUE`4gK;gvE;s1|PA8-n zX`Dj`N`DEoF0V1VvA)>`xwaTz0um4_WBx3x?BFNC2+&U(aj@ve(78cs zbnDMdKd=RzZnGi1KKBdzi$nV&Be}I#jrAO#s?9DtHlT4ieFoRCFVvp4N81+111)Kr zw_$=(?5f!A@L7yXpQ$;tw_;nzZ416~$M~JQTML`MuyE++JspHttL!-VeP`G1(N#CE z%R1XP_b$6*dyyLlPM@N&|Ea7`9Ya0@<512^g8ITSoUHG3?7 zO{NBu;NPbuLFT3GQ)x*C+h=7ZQNLbQy6&8`q)^f#ekPsur}bI5@|%x6 z_OqYizxXF77EjtYZ*_40@&vq7yd~r}$q|UHiJ?ZW9V)cNdDR@hLe>yKMHrtyt3wQq zoa(`R_iCUO5+0!qoMrF<6WBj@4bFGOq=j?%x9Hv)Z%4F)FaVw=*-`qzgg`JMAZ~(x z;D7ebxX&L|Ib1IHJC$wm4ljx-+%A_xr3+~^AtXw)Cae6!i|(3ib9{TY#@!WXAAzD; zVV}tDal6A%ehxlGes+aGFeVU$I-;%VTB3GTAQ+|j{L*(?qa9Anyu5U&K)gWz7Z*Fk z6I*`|`paZ#nn+BdD^LF>Lk&_G@ld|*M*21GIzI>kblo!aS5~%+ph|@ZWu^3)(-6o8 z8@N=U65RbjVsN6OEHLy?&3N|4)4S%5&OGAuFXtatzK4i=7Tg2jrwO@#0slJp(CJI( zj?O&(^n>#q!TtRs@dW?(xW9;ti4sqg-d#;xz(4rW20;n)1za=zH1P!et@1TE=myv9 zBaCaOpI$4tnR}Oai8uLwfb+N?5SUXsX&jkL6K}FNvd?mPcGPCP4#mrGM<0@ePQy$gM3`f1d{Jh$fK;&wshY=250QX>9)STn;< z6CkC6BBoR1PuTa__e&oN{=)va^ep>s>2J{YQ0YVR_i!yAu2njDN(CZ+KV2lur9boE z`$$e~B>WJ_9G$%{8zwCLpO!T~1VuWK{#LZFon3}*X{SA(Jjou(pxfwm=r$N>xE(LV zrU;tLOS-qR6^y6Ote=N}pVvJNe~jp8NXnZ%Zuu zt^nLAz;E;P!Q-V=a}+{Ky%J4*X_(y&RAMifq!0Y#zuAv6#B%awVo!bPvFXvPLELct ztAyX1Ir?(HhbqTPD7NWWW+C}!_t5>1wWG?(Nory`S-Lrc1biLR)t9zkb-Duf(V6E{ zW{$_Svs^|z!M=)Oz>gP{-aP_b`}$fzY3)a^3xc!H1pfXS@OSxlWBQ(+Mrd{M0{j=u5f zh8P{mmF`s?dH&+DoI_-A=|x`qxefSv=MndlU!+&@6xg0p`%SYc*svZ`^rybv5u98l z5Y>hPbs}48Xb(!C`EIQo+5A6AA8hYg{Hl69@@i;~tF_BSt168v9> zzcu(LgZQwaW}RVh@L^Ybve|HX^#zg(BrlfU`spsovlq0k`vy`~OW4ylN>Dl>kw|`8 z<9_jB*h`Vu4DYob>^vAX-amCM`>Fn?^;CbwSY{grC19=0)XGbtchmTUH>D`_CM1`1YX zXh>7h=@~~FSk;YziIubl6p6|(aE%@h3BIaQuhplrJ~y8Rg%IjGyB)}#&hnhtmUkeG z*Ys!50@HK7kNCz8O8rHX(ImZBIx=8Bf{Q>8mgAFy`m^aZECAw5K?iMOPcsY>D@qT1u9qj0zK(2F`=fQ+e|?_+{d=VTpQoOC z$enXa{bN%9+xb@iP|i2i<@KjhW2Rdh4*rs@WZxxT@%c;bQYoRhd%~YyFwPTkv`w5t z+g{#6$VNH;{~mP-*qy`)Rm1)t(YT0q9AzJuqTWx@`icKzTE8$y>-Mv0ZJed`&y77& zsqfPSul_uO!%RWE2$7_|OKrz^7Ilizs+^u$gc+}(83*Z4e^Vm)2I@y$ zMsNH121MLuWmw@w?63YTp|+1yus2oReVbu2WVY}k0FJA$Rg(cffQ(L z*@27#tr%+bK?WSj__AO+u@~=sGIzm_vjAkHK$g;aQvFB7l{MNAu;o-vwRw9 zpGlqivayPyQjY5NXzN4-%Kr8htvc!&s5-nNzHnrX+PbqPrqx~cs;HI->jgTkX!!h= z)jz13%n-2nb_7nToX!z^>PbtUzd%96?3^UTvUPjuxkd_bk2*qyjC>FmP6^|pj-06VDpgAE74Atic9A#dysbM#TU@=uuDGh}5ueKH4ewYS-$KWQ zBKF3ZUnSflvJ6}H`qW7mQp6ir$|2W8yS9jg@v8clnQD)%bThHurxqIYaUPtIQP*{IKjXJ!CyeSYymVK@I;o8wb)h3CG=wW0Qdzdy^fsyg)0qZpdsU) z%1KmO{f}9VPfVCi-k`$OJJ4C}`N9eR#p`VWy%cgbor~9ONYx!!Ugv~T9%#peWQ#>( zl3l{KC73LF@?J(R6)scQ1852%n^DUU1O>4BRKYj4(p@RnDx_AIUKjD|eeJ92ql4`Q z!zFK7W_T)#4egG3LB0Q`#No7qSb=fjv-eOUDhU37O}vYMt2J(41cA<7K%TN64Zwo< ze+Pt65d~oiKt92GmdS!UR?WQ_-qM7l1s=cx#nA#@oP!yjLkyfd%FO-<>iuW3&|o7; zHt`eoCU)ksjc=i@Um8WmDb z*C;Lgc%eDx>+7^tr0ZiQw?-^8*)5Ho{gLcMgU6C=38mMJw`oM<29-`?>s)hUNr<0V za>b@$4__)4uzzS4@B0}_J%9cWuIi&C1^~Sx)_xQF^=*)|sg|k4O4#4nhgQhU?w-{} zGeRbI;H z{*E4}x(Gf(zsJ8%%@EWJR2S%>JPOndo)TsQsZ#U6_n@a9UdMjI>e-)PhKi@rjo(YD z?JT+l9Yu;O*PGbCvq#w1*`xd6mw-e11^WrJ8t&sqByrcw$2>kF@56fa;nO#x-Bc6U z!EhbYFm8WV4bBcF<5Gh3M!R=NcJRL?vWW~`X_{&>Km7`yT_*ad_mcrJ*o~r(!2~CH zM^E2__Rih|Vn(03OB(K?=5CAjo_()h;VD`qKc=kTr_fIP3`{$U*M0gv zVjp*35lCXk+@0x^hVe0wZ0}-H8mP0Dg_Vbdj>EQjs1e^5wfuJ3;oDyAW8}wGJ0#I5 zTPNSkH%gjTm!HRXJn_u8K9mHt;SWCCWnhOAyOJL1T3xF8nxy5$|H zI)V~?es9`aK(v+ya}Do{hX_F8`zv8-=O!I&NC4MCwlqc};vdof!Lhm$;JzGSiGC+f zDBwTDm+I_(Qyy8Cb$1M=QaS~RL{fo9tzLA6#vRf{S2dd(jXJl@rh*)2jj(>eSrzrG zpq8{%Hw61HDa6wTiQ43`lpZ5G?bU+8(qm9CMJuvb+gVXNd(DkX2_dgLxoGgh4AcTB zjS{hJT}3ezY$Cr%MzeLvdN^cH;X|&KeR2NqiY5L;EVu-H@3v1~QmEB3bi{Psw}1Bh z3C(SkwZEsgAzz~a&^;8B6#1eO&Gaf?by%Ay8r)*2mIGxZ4((OjobYaH!G0B)(4j{5 zEur41rsBSs+_Tgdr_@I9PPwffIr_#7Y&yAL!r!=R$*6{wvq!8o714slA|}H3OXJ%MA{W?_uoxl%qt2$U6zzOr&nBciYVc0Hjj!m#{@T_C{tmCuWsnwZf zmyRyDba^JT{L%%ZmoCfnhI%(Ow{GnVhx@j+HgD<;Jyw^lsxRi!b;P#0qtn{CBVza5 z5%MfLxwfim?a4)puU}VHweI?b9lHi2kYU}?dEQ_wHh3QKZoajxI#+Bg#quq!HC4ri z-0Wv5nr~^VuBvZ@c|r3$>|MmQ#P?wbDIKim0r2Vb)IRJE_hfy5Gxqtx=Luq(J$Kl_y8g%^~fSDur;yoeDknWC7rWS8ifr= zgb}qJWNR%Jg*KoDchE_=yoe6CNc?UD&&ys$t)O}#zIXsef(DBWDntU(&^o5A%kMpDUn|uz3?6w%~UlYN82&- zh^a$(GZ;C6b4Bqd7(`jHfS|qH3h-(fxB_grTr;+T;9tfE4drj#G=LvMvku=tt+5kE zzeq+JmS|NK=}L|C7Gt2lN-h=O@9NABYs4=(Bg5$)zhM%QPR){CUpc;ZgIXcDv}>c-H#WHLItt`d;(HEnUs4 zuYTYA_QI}%s%!4ZKfZWl_vW6Vq2_Lq-#^k7xLm^K^bQ%R zOxy5$DgEm7qlA3sD)5{nz?WDLRv{PH!>>nsvoySm9$R~L1XzJg9f1#VK@=Ti8Kkmm z66!~hKqJ2of+8F%0Vk=>Iws^^m&`0_gLGi_LKD1sRJT;Lou#t zY>YXC44HDot_sQ{GlDCK##a+AS3K%+rhb@kx)L#`Gqr`?+v+HWAK5o#58I;x8&M$i z#gT}JNJhg<-BHHWJQSd5C?ag8V`xw50qts%eW-J(j&{f6E>|q!KujX;az_*3l0v0{ z(~q(0nH?~fcyw9s!5RxIoK<-)p^t>7KVgm&4;>}Ax#urEKp1Ck;k4NYq+6&L}BLxZ0Y|z;_U5@83-A8+g7M>5jm&#{nN<0QB(1oxI8R7*>51>EIY{xn$ zja^s}#q?+OS@ioSo(?_nMC6Gl5|2Lm=o3%FLFDNt0*^nA^-lN=brOE#M{2kSeil?W zfOhBu^;75reilp*IQu?FhrI-kxRd-5_}Z$=YhbzV5dfdg*Z`pL;0zfY#pUok<;wmU z;!d$jD^$CDac5}Q?}UWWijXvxN@+x%SfxQ1vZ*%9jV`slXi*1Y$CQ{hR*}%Sti($a zJ5-`Hxm1iuC@?}|L1-*0kA`eYt6!%L20eW;uivk-c%0EnrOhkli})mXPk7Z3)Ej}!bnF^| z7l&vDJeR|TL5Uqu@-mRD4jL>xoe!%f*pSc%tR<$B#7V1(dGYYL4E%~l1)ma#;v3Gp zu%)4uamK2w9@8Pdr(oKn6q&V&zABv%cAat7wa6U~yCO!lr#g)9?a1A4>RY^e#X^XR z>#qCwp~-+N8_-EsAtVqCMFxUl%XwSkDv{Te5D0MHI5+zvvWlw3kz=fI!vAF)8}4@* z(}p$OGNx@-<%LxdFFRlabJXN|k0J8-9m}^k)6Q+Jl_6eMs%W|~+fvKdM*KGg6b2DV zG9sl!;H>W3JmRgg^M#Cf@ga@0%GVQ63Pm#iih4UDf}ZM_TqV&QoM5D4sCO#V9qpcI z?+#PdzRqCfc!ab@*60|pwO~XFz!O{!fR2U$Fl;}8 zy~7LzfCmV3WMQKkI8WZ^t<0(RokwF<$_-71~JF3$!8ylCw8Fm(KhNWyjXr zBT&LcfUXou_#{HL>mFzm&{82uQ%`IZDKm(YiNWf#*NnauTLKBL2v`aFrJ7ko-bB@L z_2NKXks-YXiauc=0#MpR0+qSsuKAi`WqAE;J!wpN;vq`t3!<(O<|?U_FJVg;zdIZ% z1hhh-&gX#FqNJ22=HlVMOH_c^ECFm-~8gP=K0PATv z1psH~VsIDw>eZDO-l8r#qEp#J&iW;V?`+lh%nF&wr@z6La%&|@qjdZsv(F?YS4yHk z(+^eD=g{?2&YFbv?ZA?$wW-{uRSTS^#OA#R3jTrKcH66Jn@2gt^@l-^lY06kuC@jq zQ!XyX?K_Ak8|*(`o`29s92vlF9Bx3%UBl(7U~vPQa~L=b0wMLVeamp5Arn&Z#U#x) zuWb)I!%(ftF4v3{MGg_8td|Px(Q1#+cUQI8g#x^Jl(I%BZ`!8t591%;mWX|8lIsl`w<%9N>e!pcw&;cLucc{1 zsC)|X2lwx@K$%1_$y^sYBbA8cMSRi_b&iS`ZqGWHrP?2Nd2SvY`f z&`+vFQByO&2CNT{#{9w23VXD0IXZ`@Ak^-rM4VVOw- z#g#ZeJmU3%uW=v9UTjHbK4U@3a?3-30IvX)_yKA{Q4+r|TUns88CY6Diw_wLgBbIoY5nJB;7m^kc*|XEt07S z2pNF}*3-#MW%?+4r%E6Y2>-ZUCMh<2?c0&WkrH|H~lH(!V z_|`K915}{qi^Ek;(&0#q_S74pptq8;gsO^79&^Y61%_l+i-q0)5l9#X5KQHA`GQ;a zl4j$TTR~vJ>$C68=qMhnn*g-{ZMy)zLP+92^UekO%qyQ+QGwCRT6p=TAu8wkcMB}u zNf#tIdTxAyHJQnKt6DuIK?`%eOA;y=HsnebvSEgnS_4|&&id+VpIhqox=gSX3q^@- zb%D@|sW*R5E2)p(>1eBOZf@thD)M%p=@{Qvv>Z^2tlHFIwO)`g)w=6Dln$3uk*K#? znb&gYAF+@~Eg=|jwAw9^81%_S11)ATL8@qg$2N9&tr4e|W|VfbJyEa>F5kF%5#BiQ z7DP@|1uOIC9U-m8oAw5L;gBaE(n;1K86#W}8wp3P`*&5S#XfVA5i<64kLj;_F_>be za;BGj8F=f`&!*?6c!*n4U=IAdB_CY(^9%+ell$|p7k;2N{DP0BD-y<7BIB%V^wI>u z#G8jAa;coOM>25_Mae9%+rj$kV35zZ&>Fwza0k|`vrZ9{yC7=ZZ#&FaLpIyY zVkmq@3;Y?EQl(HPCI;Iq;Kfu?wpdMLo7W7IoTM}^y#ba&zswTMhl4+z_{x~nER(97 zzLVcSKO-Y)Mj#rFIvZk%1II~|@%DoZW7XT_1kp5Nk`fXNKM`+T;U)#JTSixCU1*>p zM7x^3@t_dw_L&0jXH=fDK-Sor*%2wb+&@F~z&P-=pkZKz1C}8_)**Fxi2{W~k7X4F zd+Tx>?*rJ`S$YbIM+8p@9lB@Z21_DcH}0Chw_2C8aR>?`b3cYlSi7v6rdwXJx#i4ijb1`(YqWjxVW zm(LeS*MdH!MK7~wS^|~pR*w7R8p@k%?;i@-VRH?CG*|1fi`6+qDL@iTr7AZ#Ruc&H z8b%~#NJM@2#I+C&x%O~8x8tc^P=sJXrOspjDwD(3EQ{(VbHKmrXRfEdNVW2|mZ?Pm zZ+@-}1oF(QMLy8a=DD<7c>s%(SyhfhpO&cv-V(^!<7iw~t%xhzTCL#n@#LBugg{z1 zyfUehDQIt|p(Y_>L>9l+yQ?{0EtGl&`Iku~eDObyI2^aPfBk#3ocQ7mdW9O)Dy`S9 zEvr=l4__ddS^;IPB+gvK27*uM^&{ftzK)) zt>+5`X1&oHI{e(zS3|8!<}M;4lTR^YODL;(VVB7p-J0|F1*A3;C}V|<5l4H$Pg049 zT5Zy&`xgz9G|OY~ z5d$Ni4+DTSo$o5In{l66;|yrs zYcgW-xJ`=eg?LTf@{JA`MIFDA)(9`VP#|z*wR*Rf51aE5Xe}A{oT!0v7EF-vW246;M9q1ZicC8 zvbA+}*t`=KH$|^lOQ^M5mWf2cv3PpJx)q`2)rT%Pkn^-SXuepKhv@Q9BNBDnHq;KT zvhcUI^iZTld)nWK_^-kMxeqe;fPjMcqkG!yU}`KLmO1R=9-0} zrfgKr0H5(K8@sfkNcj6du=N*|O{QcFcD zxq^}K9pvJ1kv7=4J@3@?l4#j-fx=$5Xm7iXxDtdpL-9S84vkhSjgJksS;YiWQubJN zLz~MIuqsFrB!FDrd3B$etO9GnnZ9*qz6{Zn zx;Y<7xV#>P!U9^bL?YsQy0fXe`aKs;TzRDT1%%SoUU#t;&@~f{078d>m^nDJ67+|9 z-m3pVx^jlC9&|=*M}3wKF{@H>!W9eNviRV59X&lUZipl@?reKRf9Ae#s{4;73HjKXzTWv{U(Vgn9St*jBSyN-dn?|;ZGv|z`|T2dEFNY zz2TV~+|Ftwr7UWaW4i^DF!T9Um6|z@~L-rzc>7J(T!- zWQpbNVv$&D^VqdWuD2;n{h_5Rnl{F3>ze^OB9LgsvcRHAx5g#!u0+SKCFT5+7n@W$ zyEI_bnJ8MLu>yxFOyh)18gmLoAn$4Mrh_&IP3k2!sg_Zp-*h($l@dNd8oUNA`2HFS zS)Bk$3B5a#OdB;;4XpecQ*HB(y-SKsQW_pAB$;@lM-$O)U9)^WsZ#Dp9I|myK5{WkT2&OGL^DIwF@ra)Q2F(H4j~Fom$|#Nm zapDp$;WpqRuzWBfXL;)LyeMn8r6=~i{ft&>V+Ll;g!7(^RS zR%qQeg;6PShJyM~H04S)ht#gBHlIu?gDv>HMz!6qaji&eG;+Dtt`e(sLXFFxaD_($ zE(=2+InGxL4(t{&(o{$mudLLFy>T-`@I?ZZE0$Inm11#=uC0fF7VxpjWZs}gde~uu zt&b$;Xst6gKGN-wsYpjfp{dPf_87%ZzZ~`n)rLHBKqA1lFD{ToB7u}3H|`>Ix?NKe zsm>gph{l0!?9o3w@)Zjb#Gn2 zt@AY`$k3nuwsTkg&hv#vRsV{d_8a62FR9qv{I_rYKt*qU?>!j5>BXnNHZxGV7yPCk zybJ8vQ8_)fW3dwq2UyNTXdx}pkALPTajSUwGKpl_3iS5u0b4nDkQXms zDi$wYK|Y7CSS}Woo}D}R&g=o&%Kca*;t|syoqmq}0nnwqpAP5EDc3f@YDBpP(wTTC z`-4EkU@$z=6b?3y#6pAhf%-&Czf&E66u8=vlsf3tPCr_;u+HtSTUZ5~mblz?<15|G zDTi3+HtLGCA)(F#wz;0Vom#=H=X|J1J?Oj;iv!z34qL1+J~soPSMVV~1PV}^AYmXR z1smT1umZ$U9ZKnOjEaHH@v<56g~bqgY7>W=oP$2GlYaKg_YYPKI9Jz3R_iR?tHpz{ z&fyVV&9x1SUplXbS)c5#xb288`;qSox>d*jI-?1>E#M3B^RE%x#=jD~v*D7K4FMg0 zTx}kH!AyVeTiC@r(+O+NoAjUYK9p*P4|AT+=-phwJ$EqspLCyEn0i=1Wrb z<_#<9#~(Cn4a(~;G3fLfZ&NMQ4Tq71tVkwJ?g9T<4WL(tcuRQedAsNM5KCXI{bNl7 z>;mxc!E?&BN#Ge}9~;LAX9t$Ki2^JOGFrz0nP!g}4!~6YH53<}zY}blGk;7C+;aK4 zFW0oce~Uk+{+1q}J-BD~vo}`{w`y!eHM0lo+yTx-V|ZCml8%FqtN^t-MnM1F$lC+- zCX(=v(B=dN2iK05x*p{v%{ z3BNE)Lw0EP05#7Y3;{`Dyz1HK=MG+(JpfsRW6w_Pk2w6Ugp?pwL%-Z`;zFTAEbwJ(0r-rh7$spaHSLe{qNaId{A zX7JGBQh6}F;qr}(V4k7;x<0J?(a6ZqVWM9&3s(~3%c~QfnBYcWPeM4}I55NDI`~WU z6Xv8+zg;F{C4}4@<6d58aT$|ZDszo4msU)Y&`CieN$@&{9yi&tn*wQ%U!Inl+STk% z$%$mlCWGGXH>Y;=EvYSB{ zg&ih#nCs4QS}YhMCYQ==zLErmDmK{z>25JRUaWCB4LRjaiAJY4nu*Nvx*OXkrd@K> z?{CbE;n)aIa#lioWB1I$>V=cqQlfn>{pkI|$oC^#$;%)gkPPbp2N%%E@E^UMhBi;2 z@M#xt_bCa$CIV(O2oQV;F2plrAtO!fH^`z45n@w>TLBek8qLa;ZEhnG;9sosQ5u;R zZJ1QIO7Zwbd0vf7NB%=5{%Yd_qN8kz$1PjD)TkCy=b`0j?sl3~@;}x~lq_1M0gfQe zhLl*T7w{N4L=#y{wE|KYbI(w47-%L=m%hLAB}^>Zrf!omRI52-!9HcG{qok<4^*mL6O!eu&7J1gTma8V8@UU5j%Ps? z7QVKI8XKdY);ge~6JrVi59bCroEz#AV7~5v+kyJfjn<-Kb`Hy5u49kXZ%i_7DC$#O zsfw%R3?V9ujpH}GV`uqPVi(%Q8jPEl+LKeKByC@6wJ)m~5Z0Rz#f@wM?v+NUL(2rq zrZc)j4m-FT?ld>ymVg-sFmKQqgi1>=!5G&Vz()scuwM+-vPPZQ2)Bejt)9MXj*+z$ z5}S2i#*((VFa5&hg)a0^E@kMCkS0yGCxt=@%dWo!Y5Q2cp6}_~o6l9xrpQN|mFsN7 z*B_6!ZCQZGdCrNpYgpfcSkGm(?SDy658CF`tTV7|&APpl`j-=jR3!gPr;BFkSxLV2)IGW0M6XGW#u8exG|C|B}8!ueaE^2gHx)7xaiTS!Vi1_NCnGP~?+EQw}EO zw%7AdCg?LWR>m+^)^qKCCq#-!lss}V`4m-yv0frCNU}Si4MG``K1Ab9H7b$QjzKSi zFKmQ0fSMWj3nz~TXb8GDqkr`PbKdgmPBqn9lp5UXtKfN^it;LS&+oG*$n|Vjwx85R zF^ttAS#fnW(PeSF83rZnV%9~GY)Y;-$|sRUHuX6mE3Do(cso%rgK2L-zua8_XCYx^ zHfaMKo>jPM1m94s2Ov4@k{)jm#uSd&rVeE?jLhXkCCGK9ISWcS|1PxKhINYxSCAdlTHsoMy`zEG8(J(t@sM4wfh7vT z2q2vsivib8%wmJSU>&S6R?e<7vFBtV%Vqh^tZ4~T~WE;tzAq!jPy(XnzZu}lyzgd@u3gVJ=8>qJq=h3X<=jFNnnu04mX(w z$YVcV6B?8d6fOVNGOr@Hkw47n_1oF_v^*`LPEGPq6IU?KHua3fTlDB3eYI(GwE=6+ zH#sXNbb}2l3>Og3fjj_O2vm;*T85JlWrMcyg#vy8i$BZ>l3DJwmsu_eEj}PQCR1IrO?t40)|FMq0EPSl!6_)=>jXpu1i~DaczV#3W?jezqzm2p~ zGX-1c$1(*QD{){=LaE^~ZYVi*fbM~u4QQVAN@rEgLS+eGQ-yl?;Es;Tex0_|(zoO|V9+O(bfBH6C+bFD>2*xpW_NCs zyOgQJb@SJU%gJcC!*q;}M6ZQMlC%zK;JCwJKovlI5AlZ(gn+u!11K3ENEce1qHRs; zypuulyQ;UDT{>BYo8QTQxou^}`7&*0W=e{jDrESJdt3Pri*A5k@2@m1xbtHZ)Zz9X zTAR|oHRq~_+oG8av&z_7X?PFTgcyNhFruI8hPV_~AFA2xB;#IgoGk$QcwCU`{3&{VgLfBti3FLKTvdR@;>HpC=Wp#kJ9 zWUKj4whi(>K@Q_H=*Q9nIB^{?*I*g;`?PS7pcoF<9Xa8G6V4Q_eseo$o!{sWxEv_y zl|&ymBe{BK&lV2lT*fsvC1)A4yo<6pjhU+Ei^_U92g_-%XUiOp#zxND__NW=oeO)N zG4c>Fq|Dt@DN(%|a~+sZWGlxN>TY*k`bvNw8db2KB{m=nF! zwYJcFUfV3DaOxZU7mwYH_eW$HxsBKeGAjiJ0g)C<0Vo^^gro+e15P065OTMI@&*{8 zAI5;SmEr0M?!p3eF9Mf{tcsMV)$z^p$hf-O7-rQ4-Oqa8BR7&qqGohWsP9tqc9M*Y zOi0|%AOGUIB|oJt={YLDdHVLqJr?;RFa%i3IAT5U6zAb8Z3GrM7_x#Z3!V`jgAFnS z%?7^_HKZ5}9f^jgC1U==C|@m7&eRdVQ0r!<+G8K=^=CuVvTvK0aHg$WN3m`};%0Ih zoOy}hbI@?uy}(`L#^pF6qgLVt{V#|H7(zYFhse=KEVi>Y{`O+-*1|nD*3fnHt!hP1 zV12EQ*=h%k@t$YfHO3dUQ!ckySv&tj4HJ;bO>@^TaD{AzyYM`4UGYYujw%AIF+|hI z;HM?nnR4J+fI{;_-VBxZE>HkVbwptt|IPM@8#M+ARg{#z==Nk2d&i2E^D^!%Ea?$G zOFfcLyhzoEV1E)bvNm6S8%d zK6Fr_BdC~{eD1i5mlUA{9skqY-|!#R&(5^mrlWMV^}!a)&~rDsOY#Wu^3+*zOWT;t z+03kx?9|e}4NXjO{H!PWkN+u*Cx$qO+(f0qdg7A>E0%=tm3-SYzvXfMmsN+-)0=OQ+v62d zrp)}+4pZgR0sOp?>xfn|1Nfk5!A^o$=0TGX@QHocP&;S`9svFZTsM4D;EDmthVehZ zpl3ZguQriE$<~S8v!1PB4R_sHsFg<4_JQMiab;fVI+nV+?bZ&PVc;J7IV}0f`pInn zIiIJSUS^jXY=!JKTOa7Ka|UhK4Q_)W(Oz>u3-uf>VilzqrvTP67I0P}SOW^$4?s$p z4g}q2<>)+t$hg?pjU&c|-GMXvJk&>Mcm3>Wqp`bg9L^8)YZ#dc+%*$%4p8fMX#CjT zK_me?CAdD; z0pH1Oz&Ujq+V{AhGiyF{4>Z0O(L3iRwCsz^ zwFhOgbYHRCY&L&csFg7~x0cB?*Xp<^wSh4kBi!cXKE_qEn}1>By&#WJ6G#++-}W6? zu|xs4gvX}G^}ZnVU{ZVmaMW$s*1(bU2N8I+L4N=as~^hQA)o*|Ps+G&E4K9(=U=hj zbc8eUADZ6#i(QA7l;6)A-6gY3cf^+W{*|dTr&Hf?+?%G7{FGi($`RA250~d$n75aI zDyPCRGs0v_;dYrTwbAJ?Ci}>((C0o6Y>-RnT@2?Cpnl*_hhTJWa5J1nz-oei!)C(~ zAOLX(H334L6>^htEKi@Cy-mNEE!pvG4TBz=lvug3?yfBcC^r4LwffwWZb>8?3{rV4 z8!$MUo?{ptDqudjzhtnwz|i^@Kc(tphNy&FbxYrtR)hwEhw|i`>Rh=C<#Nv5>8q~3?V6~A z)z_?n((%@TjG69-syR+x@{AEj>~4D~WHT82#PNv82Q_-C z>IKvi(XNvrf?&C8imtdciV%}p)?nywXovaAiEZQx@aI7*V&4)29Y8CB1v7;3t5I*o zmc|XqUpQT^=Q*t;s-g@@GP^iQ%HCKpc_Aw^@0@ng(&-M}At_;|ybO~%X_;85n_5N5 z=3T09Z=IVWV=mg$FOe=RhL&C1;kre>G1ipHdPgT|ihq_r{8_yIFA!=g^r}j!Txo7u`7Pc*7qOPq zf$xad5rQNuoJV|5mIt|Oa0&ddh~PN^O%Vu`JJxOY5=cubYojEW7-hNpbsFkY0&UP3 z6KV}Cha&ymCd7Y=v=lOspUIH)R(Ha{IjfWiiKP*@O}2hK5pi2@<(d(}u5KCx>n|dI z`5Nx{VxWFi3)SO5IEwIz0;3HT5k?3{K!j;8_S~_iup`<@1A|6DN`(~Dh}5*^<-Gd%My{#O1q6)GoIeTUA^My2-zdE&YS^~RZ1 z^iO>8ef%*NZAbMin=rhKdnCM6jHIToluA@+)@w+`hc5m&3=!;nDBMY8LvBHe?SR$* zYSj%16EqsDVd@3&qu}NczSjm|$l&IKu!BYeH4H})N`&Ez(htPwnwVT9XBoL=pj@v{ zvzmj@c~-20D~$~-dugk_{2Go!Bq<@&CpXtsHD?^ZRT2Hp-Ner9$x-~;mIGIO@UlXQ zCZ?lL(VFaonG>p&@-6#}`OmHH;xGM*E^3~F zN4!8S61;i{Vn=l{XlJOJ~RJL3LXnMW;R zFN4YEO&=Uu72q3KV#gz%D?L9j$z876X8epaVm^5cd=6l3!D?1W&x~e?PA`%UL2%LY z=2@-x`s&PP52O<_FVm{2Z3J4SHpExz85TvPv>OrsPo$xco+A~NkrLv`vd`LQI36jD zA6rHRAr@lRZ~>f?E|`Cokdp;N3p)&mC&;*fpxR07tGPwM200bjA34@jxYK z?EfJAii!K>RiY@OTiueln>><3Hse{+A}vE&??&`gx16J|>U-P9OVEuaEsQxwwYXUIxr#jrvH&(wLz$ zTy1e!Qd7-3T{7{8!Fa7oLlxXrWvEq4K^-&f_N+}COaw)$WLj-|Ih6gNUnSHG(FpAo z7@1^G$j!y9~i zc#X``N^8vgCGSmy&Pqoc%atg3YQ*Gkf*x{cLVbGLGMMAY$g#Mg?cv8IFF-BD8i9g9LdObKTo~{KmJmEEcgUtdX5<7DB?2oItA#=@qXAAR zzC?0~oZ%3y%Xf;Uv_x{?y1fcEmj8*Rv*UButSd{TLg%M2Y!yFc=k5H_H1-)(c*V?_ zsMRXD`Y|0*7nm4DU)uaIpUxlWuV&e*LYFyY+e*`0ivy;8Rx4Bn+_KzUa@XFn=83_J zB~Nbt`g$Vq2EP3-ODGju@=!#?L$(Z7HmR=JM6fSmS>$!p9%><7g7;7a9zI;Jcmb^8 zq`;!O#1xJ?oFXxmTxw;)5AZ8r)S8(W2^#IzZ_}Q`-^5=Y{yRhafqd~LLM?f1jW6WP z|C&k+zMm}~NGDC+bbboKc0Gm4aq@{6x_Rh6(JZkGF`&}GlH+Voje_FSj{cFgPjxb(rErzojvRju#sbFW1TCx6+b z&#s#*8JN9f56V1blt|Q^cq^G6xgevF9Bfa_hr%|G#g>z7kG#s~koSvS<(g=dWde&3 zQA^ifvavfc;e1-VlcXrOW|p>-E-P5s@rG2hXZNHRE9E~AE*vnj^^$|$Wc~>?i@Z=~ z(N8pcz1^#c*?u6UMm1t}s57cEMuOz|Mw=vOt|GnAoo4kqiZbwzD_|Mnp8dYz9+W4n z;{Ztf_}0-V#P;AqL#hmX3l#~@8u%AJfRBQPgtda;3dfVMkO6#IE-WH;bYM;KDw@x@ zdit%|+q$f`1DLZmL!u*x@kXV*lD&#GKj8#v>F z{qlhr$*Sz0-UGF9OAGHnS0H^LMr`RU&Cw0NA-PVrmJwg0m-LEn&tn$lD}x8bd*n6g z!A*M(f&~V7J2G^eXtS_Rd&VnoT3893GNi%0009a9(oWkUCj@IGct^nyPHSM#A8sya zSW0}g1bG}1t_?sgfu{LCkH7umMfdB6NB58lL>ULK_ z`yOemMNUy&)NRNS+eMY7O-(c!$tV8ziLOGkF*!xNdO}l6i+M%gIja`5m51h*yee;$ z5Yhylcbipb+f6k{5_(sf-8Do`GIPGECrl8nDc+v*h`DK4d}}FnXhL+QU5vOwqg|3a zUy)H17qF(p7H8rgSHdP8E2FlIw8Ngozz&8uLTsdghZtzgTyU-_P*4Ju5XdWR!!Py+ z(TnJAbSd)2i}`c;y%IiwZyQ)RK}Mpv6Ul;2w6udnhpUJJx=QT6=GkL5JwjJe&wxFP zk+uQCym_=yk95Y zhU-M&Thl>FQ1E!)OyWc0DM@%WpYY+=;kVN0yC%+D)_!k(GjV4-(MDWMhXed2TUOlW z%6)0$%a!!za2@)2qR+CUJg8LC(YG8N48Mw=jf^f;OpqAp3+KVZf1XEe7#WmA3)w9( zOdLlvPS9yi@WF%74fxleQ3olCj@-HJ?|%m^d-M~qt>0@NKs&xcmEs2)OpmflNvX7v z^n~OoNHLpKRl|ach@YlX&yO6Id)ANq87 zWk=<<8p)^ErT)Y6BJ)ICop#oyecqUY?j@h3G7vx#!&33}{Jp?ih&k+Foxx<|)Wb>q zb>S<1PemvB@?>T?m0h;0+4BU4uFzhrUCv*} z?+$;>NPQ@R7(^FH)-6g&ODp??jPrk(OZTRbnqVG3hhW;CNBNo9{~6K@ZAV{7-Ua_> z?(ik>bnO4^g;y{D!`rEi{Lj*}zD?J4AczJ1Ncee5OMo|R7B>n}-pCu6TRK0A3srzbmQW^?#E zuAD2sd?&q*9X|2gqx{d3!?2GkA?5)S1NR;HdRgG>bDi|4U)kB0g(|$~7>or88h+<* zkC7a1%IeH(-yUn2%(9bb{r1r!G<0bA8c`(w2<~-4f>$MSN(f`n7>n%LQ4jOU{JZDb z)J=Dxz3ANGJ5=e9p-9@ue@reUCHxV*CWI({BnsG%Ccs(7k*!~+gftOEVc3?z{T3_@ z93!nTU_q7R5K3?tz==RJ2aguC20Q0}Q&pg;HRz+>HS``Ort9I>){)z~Kczpa`*eMsLw{ka(SjSB;5kERusT_cPoTr8&f$LJwpe_$-9 z+nw;&f7}y#Xsn%p4@bkdP6QU%z_;cEfq%+Pp@@};=++g=3_LV7K^!a@s zp$GXDw@~Mo_0K3Onm(nB|Ei1OSIxPcwxvgV7Rwed87$XFmaV1sQ#+=vk`fw! z)kY1mytihPgx+jz(P#+is;N6DX_<){#B&!_qYubFxC6+5Z!k;?7iwa^1(%Ux$%i{$ zs8N6sI7R_k@FQF|jj6pX2a4RUWS2otyOVqZg zU|d;sbrt$x$Mtk}W2!ze_-?Q!)l@g(crZoht;>|$yc-?qYN>Ae-sUyS69b6us358! zqQ>K^Bj%86AwP#R*m@WY30N4wzFm3$i& zUDMj`CV*QiLRxt!B`LxZFg5vCEJ*hKjP=w7)XuUO=RM`T8{{oSejFv}4nwW&x zX({&RfUV3`>aO)x?(ObR%FfCLN+B;){jh3&NL*Qi*zulObWN#*dSE;w)e? z=-Ao8WWYYWDJmWLiD(!PP6j&QW;)g-On<0H%b){++xx%aa~UVvt!|exM1whV(SD`R zaYb9(Cu((`3CVNU=S@%3Y=&oS2)`HZeP#M^tkHZH? z-X!Y&L#zy6+my(^uq4XJFxoBN)Wv&CwC=uIrg9y+_xD&CqE5SOwd?{$?sUTs9@o_s zyHj=A#jJK|^3cRFurkBP!!p>vZs9f*V`U&1`x92i^)J8^zY8kt^)`-KVqj(k5xFU| znla4$j(=tHxB4wax2=^Q4jzYuc}*Ko-%Y+NQ6CI0F)P{#`nmQ?Y_pa@0u_n~3RoF*0xP3Eft3;A z3^ zBbt53^7oqF`s-}`tZ>%wE>RWiFUE%A7!}_ti~}#jFBunJhNv4SUS{~cp=MD%QG51y z8UBVd;$?{1v%<@SmEjE|i-_`Z;br)>W8-Cr@-yOP!knmjWIfp0@#AIq=F{M1i2AYd zGU1QI?~RCv9;~ySf5yx3cl{nO^U|N;G9a7BhL4HFLs0VpUWP|-fCg=%~u^z%F|Pqm1|_{tbiE&*tPyr z{X^>Ri=v_#>+w=qs*E#CT{gqvw`2n*IJ{r9dE|c3Gh<_AKzIBRD?^l?3M&&sw0VD^@?#ma==9|u^5P>dTaLk#Z&)&d#KZM;~S@Rg^<%AjvgkCho-I6mOwgI*r3HZm4dWSk{sfOXgJa4pd3{w#1UP2+>L z5G`i`YXMn#Rx}xIyqnGn*Ao8p%(xb!>9n|(@WheNMjjxF@ja@0e7KgIP77-Zj|0^L zN_6OEz-`15*FwAtSa5;_E_0_CpiLN@ttTKQ;6p(~*rz`K$)ZwVYTi%}-`>5)Xxzx> zU-0%V$X!HSYv=Fd@8Z8nPA=hECZim*$pOuY_GJ=-Z^K6(*B|?v=vlC%@cD2*ArXtj zz+ej0tdI_f#R=Y^Gq#O2PUS!37xFzQ8v%>N^+nr9cn`GWnO;4Pr;Wt#^1m#e9FIZEc|(V`hCaW3|H_^PJEp; zhKV1Mle!a{Ay@BPodJ-ST_->;MCr*m7XJD&`+`*$XMAo%)1RuzDc;n%8e?550o#Jt zh6oRajU#i30N72O4f+ppEaC6R4P-$B<++)mT+dJsqrdum^l4(bj{jTkISUS64tNYA z9>7SEiAz1j=`1sKk=nbvyNLhgusAgr-@$;Uo;ETEbRpcLod(CkxBdyof~2Rzu?%ky zUoIGRAO;iRlKOPePf7 zFYSh}4K_oF-OJ0|=hm@qej4A#67zRF%}WjJhYu5+p;=WEH*-aC9kv?}K|bdYu@UAX zU^KyQoGnIk{x~6;#Kv(!G(rCW2m8oz(BC+@d1icIxav#*K_W07fFOdr-u{s$LcmZU zBb0WZ5+4|TibShU0T4tni2sZ~`Y&A1NeDrhhRBSbAOlc~iX$MXrWANHFc%3FcS1JV z4e8`$(hbRMBYZ_B7wxVrSYIU9=_J<5)l<{^YyDEMDZjco!8g!1!Tnm&(z5uv;tW0I z%-LL8w7E!bkZQD9Gus0bD^n!_LuPSJyr;dj(V25Wg{!urBpUED8HA7coVpsGKg#wC z7kFnLhfV?sc%j_X5IX7WlPk_ot}-W{PhXuInw!C@RFcTt!8UJ8K?3d81@lXtp0=hM z+wHD_YI9HM`cBc)xR(&rZ6Yd6#l{Da=~XfpR>ALR{X&yUOztZitmw92oI1@^+DW@ z^Ol%VYqSa+QuI(_FUA)NxO^Ei0V)U*rS-0>*VV4-NJ;EGe?ryT$?4%|3g*ho*!@|NVJ z#sy_Xi`#tOra29nJq5AIRWdm{*j4CCET1UYDN!Bzf*2O-L^+t{8kjAhei!x<$~bjk zEHNo~0*b*`7Wj?=a}1159!Lj9x7#mJ`BEo%d|_#bVRs_!%4Q zq@b&?E09{7oupK0WJF}~vR)ad0%#!7v8vvAE@yOjGwskb*gSo@uQm`(DYEJeIlV=W zMYH10t0>p1q<6I}j7@6DiH-7ABufpEW<_eLKURe}iGo8iPkzU%x}@ZIo7NJnN}4eb z*)r=vXNd~Xx5O^03T}+k1fF-in|R=(Q~5VRg~1pA=^cGnXx5_71*|W)(*iqXzz^RN zyWBN%Dz;u0UpuFya!!@gZY!8vkUP03cIn~+5#EA$Pf<#Q!ISA+x@t{XNohr4wks=} z)RoPsh$E?8+luEkBzfu=R2EDtbGph0@)oaV8}!%4=O-9VNd@ul+$4*%LAh#0enoL^ zX|438o0MRdA>y>4ci>L*1?aNw1sr52^qrU`nhU!Gm%RYl9pxQ?YA?XQ3q9`*Mrf8G zY#xmn3!t>#b%MmeEoc%zxFkcekaT!ZGIGFT=rNlCW{dy~3{V@u2E&#^X99=b?J%O1 zlV(LG`c3mD^{KoR4dl7 zP>_XXeRMfN*!`e?X}C}apBb*p{60{@kd?wPA)_7`HQ{Z&-%b0qqYHNB=3Og+f|`Xp zIhGZ#-hNSS{`T|448y{*K>_KSO(j`15>FyD5^%~@>W!3EtL#REDsQ^PZr3qoi_74z zx2%jzm=b$EVmSuMSdP3_voSS`J4kV?RLVhZjiw+rM(H+F87$zq7@BNU^dKT4p@Hp; z+1=UEK}0n-!$GEo?|^&o1oWxsD$%2&&kzms#3lH`0shwuWVhi&V3q^HzQ9)itrr+A zMy-v&e<1t_>Ous)o&(%Pf`Y)u1zVyM-{irkhlFoV{6NqSC!Pi|sve`vK+-_B;3pC~ zz|sgOZj=dJxXHyoz-DcfJp%?KhR-~qXYePX-+o;u(MiJpRM_k`g&h8)l$(rD@Ev1~ z6e~3f4hV!el}5}&nbaDoKElL!DTPW&aZ)W1$C;uzrCb3$r)Z!vWR+T0OvL!bDixr7 zpr@Et!AU7GO&TK9YUm_lGDu0Sl%o`C1(Xg%m<+T=ZHi*V8a2nsq#Shp5l32MIJHyv zmrMh2nE{O{!$w(v<%}Qpd7PwGZy_BMX{SVHOM6QJv5813z zrIG_QR0e;5(_7CnCX-f7X>>q1iqLXZY>ZwX6RT31jmn4^i&zn@<5aZN6k(FGQIUXr zSHX}%j7FmrD}jlSQ<`GbAZ|1nZC1-!ngPh9fs9MHPKOqWVym=GZ}3zg*4h?;piqx z4yTUgT>3H)b_NyVBY)SAdB zDQR}tupQz=X=pP!NR0s6wi+02x`E@S5BS=q1JlhMU?*IJ&nXCq9`uw^kRgygTs}#I z=wPQPV8q83louEzsOf~OZOq#NwOF7k0f!6FM;DZ-*xhysJ0UW zsx?5v*CDr_9F(h+#4FLW)~q09EJI0H86zH8w#IT3QmBBal2aR@flmk+BHzr{p72>fF4~0dk4=> zAzIM0RJ*|2l>vIUT_l1QXea22KsRV}!7yXB19^WxG@=+aO@PY87^6|MHtJylsvoew zqwm9xf-V+x4d59B%-2t;b?KIf)Josrj7bYSTB3a=@vZHDAte)4$=>08Fs3RuDnM8yUAtQv;Vo?(Mo*00+YXNyt zA!>pCnE&?hMzu0MI(zUq;448tV+$S>CJu{&Ej0X00Fhub9d!Q2Fa&eAo%A&^5LY=^ zR6MQRiQ+4!7Zyyfh|5wij^c^g^|S3z*A*K|@#t02wuBhgB4PA0TdK>VFuAo!wK5Gu z(f0hF?BW6bKDAD#R%*2^DS=E5f6nMD_G?Pe6U1FmD?O)4)7U^ncc%p!@>0;VU6bwB==A3FD9drJAyQT1 z%g8wIJC#BKroi&aiPw)$yi$$crbVRt@V9idvke;IX4M_*DDV|>D zbXU)BHrFQmQYo^rof7XYnkv)ENFvf#k)l*?kBW3v7W!0b)zpr*`k5Od6A~u$c#J8d z3#b!bl;@6q$L~_8R0^dM#{e_X1LRby3GRH20&4W6z9%R5Kp{_hA6>p;RrHD#mK7^v zmMukJtXLhraykBH>9R*xtTwM$VP3u*z9wE@u^QgBtX>hde0kJKU!$_oeThL<3cl8{UK75n#7Y(5QrHNh}a; zVlHAdCrTf=${ekM){jNee^ZHd31)k@zU|!d1c@w%QpG3g()VxrN*!w=RXM6C{^sus zbCn8jQugB9A9%@5{(~!GN?WJrPuNJj;ZA^KX|_0kXAIgtXJrLdIyb{5_sxopwW*>q z(v(R$)igyYI}O-jJyGws=}GWN1Mv^3?5fs2Z8Qr!ozr1{q-Y|qrUs}tAv2UCSZYWO z!9i65>=OPTgzEADnC(#rrcl)j5JVr;^}_sshY#Zt0KV(MH6QprsDH!-ei(5N#ijr# z#BV}kTtY)jN;gp9fiPoo8_1mDZjRaRrjd81dEE7@*~VwF=_g_NW! zN^4nGLrf!4qLQ|xR4O(xMtw6;DE9PBiKM%%bWk!0petz6MtZ$A1&BZi)}fIPDu9Ax zD($LS9}%n0i7ZnTtBS==xjolSy@|HiBlQlYm|~(N?F2_^NJc_X8MIbnma$Ugb`LNP z{#zwmO_3H09hDY^QfEmBHbphP%VlijJJ;)P&S3NA%%{$qZQZGWVC)HbP?b=-kC5vU z#H61FvMa^_xuANo2fZa;Az;Fx^bHrlz|z7&7V5a&Y3LS)>75gmdGqBczcKo*!kOFT(>FS_)MbHZzG71kVMrQsG7RvjjFO0@Kt)0i!-nd$c1 zTa>C~i<}=>-7#q{J!4?&(7JKVfp%tk@)L6hB9x_4X-Umg@dWxdlzWD8;~Kw@p~ml8 zjB)?nu+F~5Z`0{(`~TB}aBhyG9nxL@8|CuIm~uJf7{{JUcb!2l z&wSSKoZ^Kw|1ZepIb+D>vT@9To^%Sij5)(zkWeRdaHNx-E656*rc+gs#Gavkh)@5$ zF8lSh2hmID9;hcug?gej{2mG1fC}|Q1e(`F;CiA?65Ujd3cx_0q|jO$o#c>+q>%>Q~!%=R}vDLaFkgIGOMF7=CIz#17j z0vJ-ps6h*ce(^tuo=1N{7ok|UgujqKDB%Nq+mwYpGASDDCJQg1rR^lTp@bldq<$*r zfsgrP4j>ymLVgl0p`+z~C;>Y)?LnFOX z)9>nTPrFMtFr6__mb&0V@*(nS+3*A5EBJT$=fb`s**{iXxNpG^&9ljk3rLRamJN6D zp1ZF8o38<>Zsos)rwm_3EMAi9ysN8Nqmen@eg6D#8If;uv}+bfqhz;U2)6qFpOY8_ zmCvW$0qnd$<@4#BkNKE4KkGbIHT!Ag zX!x432P<|Jfx?9czr31K>;8 z@AHLx}JbQ}N@*Y7?z1u5r@Zk-6r(R1**5B~Sc(SzfaqvybX{4Fl}za>X!2!Ce( zZY}?P3iBGSO9vow;qS3h?rjMMEqm)se zvoSM-l+^JVXFWIi$e(rB=CS63c}6}4o{?W*@BW*4Mt&J<4AkftXw&}~xu;Dh z%RL%n;8+$u9=TcO_nKN3lHJ(N#BEfK4w;iyOps=I9^>lzENK~rlLLV1kih@ z7cD%e*M&8o4Rdlpy((mb$DR|c`dGIvr%p?rb`G=utT|zfn0xQ9z`eHw->r;2 z;&Ba&rcckkM~1uq^{oFkxs3b0T%LAJ)Su-N)iDdH9dW1nOC#<^Z@>0qhAhfeszU&_Q<}hl_NP4#E-81SAe<_22^Lz%*wN zcx80<%VyO^X9Rk#wJ^6%vwCDd%>DN2Oymfx2WrK`)U!G^P|Pm%11K~5v+%OXt8ORa z(vh9Nl|d1ea~9O;a$?woj&*Pt;I2DB*fwy?({=sD?S}p3{iU)_ zK+^(-1NtnclCM)YLF@?EJ0gEb6NOwr90W)OSeV}i27O#GgCGU`lW73p!1KT?T$pAN zrsED0HG!`B&6Y#+W+zTuGDr8cOQtJZ!t9;esFzLbAfh`@A(uZAC@+ymuIOE* zA#c+1C44GBJc8QMtK{B`CYh0Fh3p(wc0eUji*0=dF0MGekmdA=eTFAExftV3^7&_| zZ0cUXV*t+`RK@_pSx>`YLI6X>+etXwuxP;AjZ-=ypTPHw*Vh@sbn9+B8{%T-p)2)V z4>$eXgbHTe{e_wtX^o-4=GA4-1@o#rv&$0nDJc0y{%cPj^y=}%Q1`NWcg93(HN%7> zo@qEZ7jUm>EZcsYXOs1U-h6$2K%YEe5np6Xv$p6NjLjc$P%L2LuwBA@PEhp-f(LCx zFgO!-7S;^6kA)5{;t+0@1I>njl@YUtLcJ@L^5IH!H*kGQi04hT-uCcER*RM`a_HmJ z-z|%`I%@RI{8z|5S2n`P_={wXo?KsAAZ0_D?-Du!2)sPGRQsM_Y~XTxUwZP3m)qFO z()ov3wqtk>OI-hVqkPV!BFUoUh{??sTj6ZzOO9oL9Qo&n2(JA0fDG6LR#-siX!J(l zO8^RVOcqGPg5s_kKBE}J#6d~{b~?5=kZr)>{-KTEp1UTH9G9qzX|Zv;cR#YFh3_6@ z^5iGr`I*-Eo)cCNPy+W$};qx?;Z@3E( zxgrAQ2k6h=afUll_jYSTAk$5@Sv>3svVWS4_H?RW2Z5JpLrwaqlFo!UHbpkUWXdlom zL1KW@?pZUci)J? zEr~?X-U18>te|o1l|!HE9Nes;SGp%2?8#aLm(c1=mg8&ql{0b^y(Z(DId6=$da;Br zG0RbRz!9{Oz zhI0~nCZtmb^@Fn4=JhdcO@V!^vs1@aGR+Kk0n=(Xq}28Dm%}Reyv9S>-IE*gg&?>QsS*%JVTE9D(c=MIfjdXmL%d0$*3*3gCXO{hs7RN=T2B@ ziEL-fBPod@L`Bm4u@{wubX$1p@N5-uE-Rw!8A$)n3~37e?d-;9M&&k%zmD8R6+`tD z*bF36>wzZ&1u#Mrh#kS&f%5>hP=GG}lhFnfhy|ReK-C65tKi=LjGLT5?Y8LI7^9=` zPnJ4MkXw@Chr;@qGGMYgqG{-yJUR6c1N36$MH(3=aW((jQe&YN&bVh}F8Kp>D^stFIrj!ecNLl<@u{pbU3tG!rJi=k*Lj$_l`$9A9@syFc^V3M64&Tl8H~heMsH4Nq!$(>Ksb*bg|uW>kx{H`WWA0dCbs5(Dm zqLK73je&xp`I8b@Hf_M!J|p~An7^2~4$VmqwZF&I&W0*t(1j8F_vC8OKchHA+_r{N z>%j>O0qqGa6_hVSKO5XhVN9j{SkBd#IFT(Pz;UlsB?ITDCi{|USwv_og=3!I^CbWB zdVVr9w+3Cs7ihefu3yKbqq;vT9ASLXBLw*XbtkM5WKe)9fOcc<20*dH8Ub@c8jS0t zA(JN$nfTiH%KJO2iVW|sH{8$ihfX<8>duX!@_;3%o8#)%mm93N@lPxtc%8rhBA^>Q zrx87b)FoQqfr~C=v2g=?7t7xd_U?YTpSEIt7@;8wXlLi>o}D0ugT^<1B&Y(~EG$`n zYUqY;jb9qO%ZJVzXp#qRoLZiPR%e`%vWoao-W5=My_ZuoUob=N%_IoTl8FA6)~Xg2 zUqBKOrnvY^e}40dxu2!XX}^Oz(6`aFJz9PT9zXc0YVv;S5~%TUV#K-VbXxGwvly!_ z9YKzz5v!(n%FEDaLc_YoXo*IfG(j1i*!C#PZn|XP{j?v+UF6X}>%)tpXZB2N>{j!7 zl8lN@Xbxjhl0x|Jt4n+=B}uHR{4`Kc#z)&w3hgZs)PC9cm@(W*tdqw00&8k zVjSRgL$nBr^!F|91ZN!gg@G1#3mO-)Y5g2f`Zkt1jNJzt*)5JqXm^}OQ|Z)PE2h3P zcnXlbQ$^L4CC^u~Wo0ESm;6++!}O%l-k8;WfB0f@c4vIF<;wa59~87bS=z_wmMkIg z_>Mb71wx-9mEc>8X)$!p@j`Em(at%TDgjQ+=~@>($?t#;I(PE7@gJ%Plu4}k9<9$o z#kZvY{Y;bv=q?)*t#bb&SE7atnL}*;n*)`Nhs6NP$xY}JVlMRt*diO&P7r0`z#f~Q zQK&g|^o6QddbHCXUQ7rIaHqWozq6zG{^m|j@oaZev@fN~>Wx-~nJ+F${Wb{dxVy`=O>J@Q%X5Xw?eZGB}*^O|{LTEX`3o)P@bJ9ZaiFF|C zr5*1pM0rk3EDf4pTTA~_HnX;dHM|dS1a^8p$wF2 z@7dj`+?Q$Z&Z66PMRJ*!ZZ^632V5yrE_3UdJu?O;rR+`55@LK(WFex-Mbt$gXCCbR zoc7$Da$$(1v5rVe+jP0Cbg3~rc4(b(uL>&0?*8+MAmjam2RsJmntAi5rX9-8YlSr$ zKsS?jP>;f$aFmdFaEt%2Nsw2-T-U($4KfE&Gn@`^B_OW*eapaKr}Mu$QSqz^ljX|y z2T~mc?m30378`1aQdESvgk@<(msNRcY6hb^_UdmiNUO=gMki;l$(j)1ENNtSN90k_ zRr=nF>pmL1$m=cN#+n@*)SE;+wL+J7V-m>HJ5XS8l)4<&&Vgg&)2`ik#{1=1E16xS zYPF%JXhkIqd%3^(vT9Fl^~}hwz4{yVvTE|NKQC*ZwzFvOOMUAS6N@&mkv3K$O*h=L zP@A>S1F_hbL<7|=&V#BntXm+(0R4g-0(VdV6Nd|4xsbYtQg58z3Hmk0Sm<&KP6h}j z_J8bTkln~~*$Mn(lXj*X?e?n08O_L;oX)brS;m;@@gBq_ziI4P)zxFQl26&UrP`0O zF?mKqyc>5Tni`7A1GS_^G5a?1|Io^c_wDGe(6Z(zu zWHxQbIY!Rzm|4R%Oj|HvQG?Ogz`YC3VFlkQ-bB5GBU+#W7jg$s!3VB6jh#b33=4(> z!ImBZ9*D%i3lrG{4-y9v(BBmjig$2acoUb@__FS%-K9`+y!ovO8COk6wgU&gewFd@ zo1k0IwWVCn`CXZg%}^XP<#XyKF4V(n^I{C@cx$A6s>5rrs_gaL@DFb~>k8~hBkA!SBX(a+)fqpFAebDv&j7xYPy=`mh+!HnE%)ZnQuO1TzNk+xGO@Y&}XLc zAJ-;P8b*U=qQ0tSs{1z=cr`Nh7T_HqoGkIti0Hd1*UN3F1>M9k`~?2(>+9y5c0vlE zc2)7#T%_SYmo!j7&#^@lksnfT&9qPi?Ul2H3Aq~ALnZ_b6&%|`l~9vTM8!bag=xx+ zNDnQPg@72=+$qWD{i)mp2@K=}E$YEme*OoNv7R4-FJv?(V^Bh_xy%OZ)}H~szUU)oJx z{?_pDufxZTL@dlPCYd$n=c7A5-8%c65*kQ$hiWBC1}#wAvywFDdL7avLP~uK_J8QJ zky5gOY6EW(YrfNr7B(Pc0LT{Z4kZr7LdXJ*Iw?p-xSSzy7L0j73l|w_Bm(@4bv{ZX z)1nQN>Q*TpzbFq@Ubh8U*k!UyCa`=2{~BNP%5@Y=>R6%=)v<_wD4Y`nOys3|C>nKD zZa`P$b4&r+N9InXNhSYdy+p~PRT@W5vSvd{tkg>YNqcZga@Jc<`b}?7ZvMau{BBPe z!(X{c)p0~B(yz_MTd>yNBXC{fOK1dG^l^D^1n0}UB%)gHg1pj6$?5BEc2L5$^I^PZdN{hbd9tW1HuM61_n>`&-Xi7h~ zblX5?Kf5f{XM3`6eISFCi>RTjV>_t}pjWj5SX`|lNWvgT43;J6nF)s*PKPim$aCBA zIMW;wEpbBlw0{&n=w1$La~9Gzqb{y@4ZjN=Si8)?W%r_(^0JAxd*1+&`78f4v^n2K z5KD-tC)Wnk5Vl;`ys8Q22 z8dFT~F+KUx+n1(dgSr0C-BqK)miYVrPZFO;qds$PIrp6Ryyv_`fe=(VbPx@Q%vrG$ zxKI*F88oyAe=!{Qu*my8l)IIz^`B3t9Y34+?o;yVN2$s+>aMQ(Yzq;GT$5^>y#SC>s^vLjaGa5jX zV0jE`RL?AJmE#9(-fX&O+ok)M|AKzJ6i`yqy#tsNiFp!8c14luf?Un3FsMg=@isvZs^mK*WI>4z2#f)gvMAI#;pMAx%H#AT47n> z9!IRSXw8eijp~F;4i%8EFRh`&p#}mbNE*TXlP@B*tI)5(HC3iK_0a}@qBheE$ZwYynvHvanbfHU8wd~sSa_at@C z{n}Oi%mAn8o-v1b4H|(?ax5;wZylak+T$02JUR=g`2xy8?)RpWD|;&>NcIjO5dZw$=Z2wzY42$wdAioqU7z!eC?*XAdS zEFT{#&BnJCA=HXAyWWV`y+BavE3tplYZ_%ww&dt!{ODpCKoxq2V{cblGr05;F zk5fb4|8aO{hu}{hGsV|XKb@09<7+iuUv62W-EccqRUx zc?I;7Z{Qu);xZk~GJs_#;;NSL8M@5Yr4=?=A7uZzl`DI}+K_v?m$`IOR0+0_nfm1A zwEekYZU3PFoF8QUJ9T!PCi*kW9~^GuSV4?=2I93?csqj}3cwnO`T=kNWKm6Oy(F@U zQ5a2;8^Jq`3Ul&Ibfm3kX2^9Vg3)nXw9~|!lDWABnxt_Y$7-u0^73|XQr(|-|5R=o zMgOa#mo+SIgyvu#%X0aV$$6}T<1`wsO3QJTIN#5{V2nq|2K`Y^8@vI*A0Wa?*>5sa3rA5mmypJ%qkzE@1%I|@7D_n85JkB7iSPpH(z5hTpf3Al>ase%cy z0Oyk09e7?cmWZHxgb9bNJm8MhgcOB*#)$g8d`AfzwV*vDaWVdrLobeN;PitLRW}08&`x+*}$96ezhj$vDFRV}_WM zhBnDgu^;32%DL52p5VpoL*?91cH8sIAY3bP98bwLo>L02ZIcWOv{ zsgDO3g2bD81IS3k0(Z{@YD=LcYPMDfLP~_ysfMu6;u&$IUJ0pD#&o7PM^9XICb$8= zHT?-Bj#D*_Y*p*l>o`s){7|T6m3*pZkBwtN<$NpVhOG$836OV%&qxhQlI&88zdkw)Zz^=7LeEpFp{HH`>dV|>h z;7VpeXiten0gWR8F@n zl~rmK-D1aGxc|--n}sJ{U$HkVEeJK_kKNbr!};s?LH5smSa6gpT~*o~=vQC2kPGkC zsK1t1ip0iJZmKBY&TB$^!jwzvM5kdroB_Rpd7>r=j>JBm@lYZFk{Uc@=4dY#s$&ox z5qj12XY5Y5;c1N{fley5Y-*}zO-gJ4w@N=XZON$A?6~i^lTp=bCvM6|*1QI-d(YBh z&>7qqPE(1E+`zC2xhvz2MNX~WqTS7niCQ%2$aLbuTSUnLbrdr-l|Z)BnY zv`Q2(9Gqk}cnww-I$VC*bUx@Snrj@h7VP=|oY);i0#-3UW&Y_bwO?_@GiE=~9v zixh960h`6_6nyST+A|**-#Q|ndYCF&rQTU3?6JjHc-9ir;fLnkd;ls&JK;i%#^3HP zov2uY6exFQb`pMrYO+mf5FYNsgn%=0MRVy<(I8k8FUV|&&%CLIa|f)PfpmAVBJ@~0 zE{Xu0Qb+H?eB_usgma8~Kr5nGu`a&rN3WhSMkC$hO!-<|73%vd`|#xkfV&hg<-G=xa`evcQ^+R;9fv51*>C)m`pqlK1us$yx9HX;AGLTv zj;Zl4Jg0h~E!(e-t5J0dosB!;$rym{=Ui|H{uKfy`3mx6OwXYIqP$G8gFY*P#10{i zvXkc6f$5ogX&&>E$!~^&iXFhkR!#v8T_?A!M7((^K;qhpwV&E?2sX<{DPT<&mwkU# zw_=0RXvt=3DuzleP{^6r z<^>EDR}bD%mn=^}nJ<+>F)y%uh&K!>tfcI7QPDD1xC849Xuny)8nRK`~ zA}`*@b#9R?qj9d#?w%Cf5gL#oVLLbG+D&g7=Npf-u+DHzww#Mzlcd;|gx}>k7s|$3AbG!cru!Ntiew&uz9iD)L)OOt0k+18 z=*#P%=m%sP0vgGjB(m$Up2l$ZVJF={PuR{qJsjyKY}c*lIHho&=hKvF+-_SjXNIL! zp~muZn3x@w7e2)(NqdtmuBr^=#o_p>_U|NCGlUc7Ht&YpQe?Uh&?3_uTD-uS@-I=($CnO@bWpctp;l-Be*(xF^ss6POC9(NMnpsS4Fz11 zim!DF1{yU^$)y6+@dv9;)80$xocUrNZxihPh`%zLeG4 zIOKoZDE+)PbZVS)p?8q@Bv3jUd|gt}-7yv>>)gj-@>1?Q{L1{M1Mpp(7EHRINJ)TCsOW$ z#Q4SUc$!DP2mTt2BcDielc6UOntgbIMfOe_Ie&EbTlgJX#neC!b-O1XLfBs4=6CH+YKCo&_F*oyAl>&J}BcOcAdaX9~M5TjNB#tGCqD z{DLw@$9gWqlVL1)+QMYtq>lHa@K}7JuN~An_*4h#;j$gDscQ@*%N?Yq4=D33MzvU;6~JQ%cs3?d^|c$f$ooKnYVd6hp(c< zdDMy!w`JMQj2KETkIKC0&()o@V_B<$5fS*RtQFFvtiKUF#fqRr51mK~ zQ_AUtAbb?gkP=nmbn?29__G*pj%aGfR(7rZe&pD(CQKb-2+OI`h%{k)(x{ z$?&k22QsY&TIKO~8TJ5$duy@V4nz3bScXvDu zfAL{y_&6zL&tyWAZtKf%o2*|dzm)y46tVqEjda30r4hu&QKVEsyDY=XzQ8v3 zwEX!-2rbye`X^rtA6cJWQQNt6t-57Zf1-j_CCE!(vd0=x1Is?xijD*xo?Na_ z*R#JRvO{Ih>+z1);V=J)7M`_X8qA#9h(qZgM9J{xXyGgp4FQNh0P_lfvX(|ooBB;4 zC;=*)5o&i3+5#;&UIV=|ir6@~UluK-JlG5|n}N!Gr*?fPP9EX6?eFwQ@O!dCnanSc zJ0UtP>llXo>@qT$6^~iUzL)+8@;Z|}a(8%oZ3R9PDoak99U%5+L-y{{$uMW#Qo4Jl zu#tKlcmr~XFkn)NBq0_Ex<)6{g>pkWHFyQ5SSMouNnc3E64WY4%cNxk4|bN+doVTG z(Fto}3NebErV66p46pWY~55<+sjxJtRyN8ms{K&+SGUnpafu7(4qxn{{iSH z{48^210bAQwd1ns&dna9?J?l7;Q%762=HQA#Y4uV<;N=I;ZufigSSvgPdNHS-CNZb z^srSM=y2)wCE|Oo740nraAiIz7DZ5_{La$CxWtI+wfGFOk0UcceVQ7FJ0owmn5ro!vKpjG$_ z?)hbzyYR*=*9AUkVr|8o2@852v1%CcbIb)!7>GV$8cnF+!+GxLeNb{7dSZ@ z6bUfLz>TtOi%`Kva1mbg@Z#}^Mit>dQFOjr;6&D~GUQ>3`BCM+);NZptV(0-u??N+ zP(Bt(Hi7S-usJ?n+hSW2`m!2@i8MStf7uhls-YY|o8uqFPeEI;; zgmMsBG=VnyQ;aa2VD7ci6ZV+yZWN|dPk^6i z0huCwU$;_BL8sdYZO+i_Z1H?9zsW7_7??1;s4^s(w#R6u^d_NHcz0G7JGP>I!$`)e zeekUn_!DXi#H=C*-qd9bH8r4-NP$)jg$4;6Oi2Dvwql04yA48Du)5V)FoSZ#A2NN1bvl@th}g*op3n2Gn}V-r|v1oa~F!E`YtdBq7WZ|rEEFsmvz zd34M(!)GOYI#V(E%`m=f(r(Cm!qOUVxr{cwU2EHbxBo{Y-F$`BP3WBDg4-kwlf;b}Nnu;E6 z;*Og7@#KW;+Lz-B1TGW(l@#u>c=?%(gWMb}Ztk%h9wyQtLb0$ExZZv=P zLHsIycOn%;twW-ZXeIjosF^H^zZNsMGF#oujMue0CZGcY!-}RUVHWP$};I?05W4#e`(P>EVU= zg@~c4Qok1DY|kyELIy;S51KJpoIF81J~uTwuVrzSI45}gkghS*=CX=}L4HE5t#eFW ztc0Gp4JO_q;ig|_EjmKPL+TkOT`(s{|Ld$vP{E7slY^sR6IXG0RLSFmB5P}A`n4WZ zyr2}<%$2!UW$;poBV!E+Id+|Q!oqd2x75N#wDneQTEGUPFXTpp$_JMUs;n z{!Jol*>vhSqJy;BrPmZw8bWR7v@hCYJ@XB^uVR9aLeWreM{YaR`a00vRYEFt1$u3~ z;bn%qDDfooKV;C!2sws94Y`Gh%;H3nTsR3pRWrsJV1Y7_WvjLA?{Mk)uRgv1=?&cE zD#&p=F|Lywe(kU7(tQ5)6AXRqGkXlB>L`ZewJ|YDP8aX2TvC*-)5T0))U{+-TlP4k zw=2svV_Shc)xE2C4v3$Y0TW{o>@Xo*fN6kE0A^;4Wn`qY%ZZ>dfZ-8@jHtC5AfN|E z9-MN*<^l2{!O;^e+lv;3_`Yx?CH9{5O*zmxwfya1Z*T8Q+ZNn54+8({ly;pnKD+xDqQpt3in_r;6+4KbSN4W>3&01YoCz%j~=008uc>I zN7scbJPm4Aac*vJrS;7QPs?<_C>)_B%#V1;??% zkUL&^R9n#Wmbl0_G2E^d_>tp2QRZewH7TQxD8D~dkhUu00sLxOp{7lxR7PO=pSK3UVC($z6l5l<)8gt|AnrSB3CCO#=d*h<_~qsL^eHe6%}^gx zQgZlV@7*O2&*SHB+Jy8fG4%v+%KGB)S7(=S9A6rnQ3^`HoI%gm8+g91HL1-2PMPH1 z9}0~y`zsS>e`SINa77gae>alSrvU~XKzI&=zs&CtG{OTG48gDE4Woj@uA&-7zQ2<4 z9;)=1QD<@LO$j46Hx4N}!gT@XdF+{4)qZt|u?w1kz* zM+~gRUDkI7UCg?cL(c^4jUZD5H6WNmB+`>J+23H6K3hj!$x67%)p?;GGLOU~!OE5$ zq@D(C^eH(IFRl$<=i47+r()=`ej|o+WM3|PC%j0-GD&c+NQDQQicHA`0D{m8p8-Qq zE$;!E1qz`%`7t*ILfh{-OAKfKEc8byq3423sjzQYBLJAU z*Vq!Ml>v4PX{2eZ<*5VILK_nrMIzMiV*f$>_G5reTxFUeUh#nGNzl&^5t?Zl4e(Na;V)f0K3m<8 zo3z)i9cd1XIu$!mK4Idn*Yo6?fID6vmo{)l`fBMacHqD=eA&kS&=NL!9EUt*JHE2geq$|{n)$IH2cXB72p&K$c; z7=LO~O6Zo)x1syaE@7h==4?g@>s#ADek18*xKLx``_YBZu4$+!xWnWI+|g%ovr_~> z!9HgS-7CIhN~WKS(_|PAVX0XA8ug%8KQl6Sj;X@;<1hN;T$jObK7w7LTXnG+`v%OC zOjIKW15j63RH^6&u3;GtK>uxHWg)WV_Xb@k$|w@!PsP=bhHOu+aI=x&zUJAOa$nyNwTnygW{C&0mO^tp zl;pWZxxPGzcaf5TTg*?NQkF24w@VG!?O-AJgLw?l1Y+KQY$b@q)vA@X_$3w zq^Nv1eGGoJ9yy1P9m%cTr_bm``5i4W2f5rCJeE(5`4#3=8p+s9C(fWr)z;XZH~`JH!=_g}tS_cMB`1znDtcTk{!) zya`}gFhBvtX?46&L(&eYiNaK4n9JZ44}-;Et6{1bp*a$f@&0T>?n4uG>Bc-(^?vOv z*Wy_mzZ1QeR?FYoR$1!lDJ-{aD0ML!r_deDX2_o*b0i}g;!Fe4;~gNTgaMZT zKgU7mM;y$#gzFgtOJIPK8x)A)Fk9HakC>6M!ekCPXkt~ZBaxy5Z~MDFPPHJP=T~|i z=H|x6GQwEvwxQkrK8m+T;o*%+w6<-tlDl_^HqfhzOHIzmR_5oiDUAzObGS?6=PS&# zXdDeOb2GfR@4%dcP}6IxAwxh+Y&)oD>tP#V2pu_-3P>ZMZwG7wUMiRfrqj2F59X6u zGG8%9nQ~Bbe77c#n+jOFZhO2^acGtLxW;8Gp3hb(G#Q(%TSmQ(g@`d+cp5jfDY>AE zZEe}D*edrP!Vbgli=Nk}jMetGShR@55NB~9#6rbT$BqP_11Jv}FDz9+W@n;JkRj*9 z<-|F}yK4+Unj>HcI9GBo2{)GjaimRK)1QG?3fhKOrLm`?_VU{3aV%9bs9HBj8RYy( z6AFCtL5)_t0*|leU`~#u_sw(xnc_up(nmGiBW*|cW(}v^Bs9X}@;tth#tt8@9apW; zhP*O9Jc2CRbP!Mhd>z8XF%ClN-ab@Wi5 z$}0q4O8xTIk}7!ks7sTtkAkZ(6V+<9UjGp2K)Oq=|Lv<=d<%&v9#c`vk$($GSm}O(~ zM{QY!45d%v_ZnAB#q+xjOPaj!`DqBpc3$Bm5hztc{3B$ZfU#Jxhw}V&1C_tES=AYhzY%`oTu^pX z=0uKbQL1S9l4i=)vLS=;124|LuSqG44ql^sni^F&1x|j9isR9UBGtt-Zo;ouCR(rn z-m>$PdvmEvFk>hTvQ+3$kdPM&d;kf!2yA5HF+mm%XfRj;w}@or2DoQnRvaQuAO45L zeAGk%&`$|xP2>QF4&L=RgP>d7rjw22#!XawasJ(LbWr$qF7N=)6{^w}b7-Hhkpt>+ zd8cp>UMTG9Inlc-0YBXkf@0AC>H-}_y-npGVau2lznsSwPvC!Q)-)GZ9>_`H;sR$X zx#oh<>_N(zArgN4RGcs~y097J)-pAmz{wxgQ}3XmW%%h2umoNCu(dvZG|6YXJ$vWU zTbWlO4l&K?kwJekL>sZh`GNCS`ywDVnXquUKJfKmVuB7%pv)a=m$zOcGH-mj-_J=Y zQ(OfUqc%GC{f zqk;BJb*J`Tq$`*@U){F>_HRSJ2p4uz=%cQ0bE( z)ZQz6)~oJ47lqGO>!DdnLmi;Lr&drN4~g@cIxV{fa=Mh!E?S>C<((UW1n zLIBAmH4D>*fieVn&P3PANlx)v7~Bf?&7esj2qp15u%d*n1z%hxqH zC;Jt+;TQ6glZGS|qWcH8`nkI5ce{n<<5OsNOXSSBk#5^jT$QjA>#%!oM9;HDiF7ZW zznT`IKNh4UGOzNTT5oNUifoSd^!sSOV zPUg~n7+;Wzm&1sg%jT;_Wxcn%q8921;1_rIen1C9jIRV=0gPvh`(hHuCDZ(Xg$pzX zHMm3>DWJivj0ab#kwQLh0&W0d2e)y+#+KlH%B_y|)0AA+OIl7VkLKR>tDgRPW5Jqm z^y>6+{Iq!Vs^#sVzSk<6nidXqSN>U`0nb|Xiv`CCUSjz6cp$HuB8tA=gIdbrZ)U4h z8)_$^p+oZU)#p5>bE=IaHmZu*`(nPdVKQ8#XFqJrp7g|jii=@T9@nBDA;;g}Gp;_; zYs?x|I{w14!KPaCM(PN22pB!VCiECC0q{A9jubQyKw|u$Tv_nKh`yBsLU;u#pl_0lyq9&CL3Nnd@7q6Xwb@^@jdURm5A;djagWWJ;=mMlmsj0{S9a0;GsVCk7>_|DSQNp19kE%-}o`*xvK zSWkVzY=Im)$n+W$e@(1_{emzFj0O9^Y`8jP=Ntj|gh{M$3hBNyBESX4D8JzkX^ZaX zUK^)XW{l$S8_%yyo90bZ`l8H}KpQ6gmY#=OB2xeyQ#}hO~H}8m9Eh2kSL?rGGT1y)eWBy)aqv){t;D7cD`- zuEO7nBsR(QZHs{+y~D{ERa{+{!sSffYPUNGApk`9H8fj%VIH0XU!sr$?S5Eu7C-GN%;~ z0dRiAOqj#k@5asJ_JgR-+fQ)#Vtoxala~hbKC-m+(S`?W@FR%oRB1yibbKxTM5RsV zVzkltgpJKS%q{V$R5|)b?=9oFCmQ$b!t3{MS1FgI)V7t@)TqVo7QztX#eE{3|A5;Dw8Yi|I_vdJBV6u_hjK+ZCpU2=8F%i!T?8ZVzfy=&ug z;nN%2f;T?TUuc}^?TUQ)@dG#3*9`nggJxO2OLW}Q1rpbT!9n=7VAoHByF&TK19%2M zvmo|RP%y@AUMCZBAXj$7S8i$rzpR@1%apV zLHvdr8cfak6D>(Yc`wFY?x$LHTkE-H{^4A>AyA7BpSgiXMRDa6)mfNlwI z;2jzT)I^Jy@8C3!llnw1R8GUcBgdGv?6g#)dqM3T`UO-{zZVY$X!(-`oGOLmwrUp^ za&4NmPjbKaQ8plN26LgO_Z;;DGY9y*M3Zy?oKoPXnZOTol4eQ?q|jOo@t|BLA^*!j zRP4w+cXN!@7b19*(JKBJ@+FO=orZvEi}!L6};UGOvgS`2>lSsVd1{LN4UjxeDFn(I3Nt4`i) zfKwsDk}JTmK+Xo$BC=6Vj%MbV#=!K4br|VDuQ~WjB**)i4>(uu^@m)0V>fYLPG8R9 zTxGtTM*x0_o0F03QGycs2R)pZQy|9mkbZe7pTYU0@(~pgp_%UT61|t+XQ$rvb&qMD zh|_O+p8H60C-mXv1vm}FS>IqaUB|oyRwbGy0094lgouz^lQK4$LQLv-@Q8!wF#Sg` zM~H<@$IWPJ7zcJryumb@Jm7ZztdZJjlEMf`RkQ6HMO3{4(xduGZ@n0=oex#&=7J-bG(4#<&zmDt>+Uegs& z7Ur2Er^0QSdz76h?_#;NrP(tjazf)s?fjuGA^VCIZE9Lu$xDa0qPB|a`fqZah?GXPa z{F6~07+BbmScTlfpa%4n-2Zj93!34Bsr8Ko0r)Ar`sHfs0hC>c-+2iqppRZ|tcqy@ z5v<>`x2~x#@L+ws<&%qND&#>rR$a!9F#4o|Ohoq9E_`p`s>py7@RsZoE{iHep!b?3pz3<21*J}*`EJaeUK@Z{{O8*#G zd`T=1%Q_k*e<`prvR0l^KVO-EpD~89VK1>#^=hblEs2rK-!DqpVHn6#dxjSVfW*zx z*9K90Q9%)Y{S1yoU%WcJI;MsCj135EEfkkDj~_gtL?$a`-xV(m%z|ZG7{-aSS^@(D zLkB;Tg;qTtjJwjd_{X8w}qPC-V56i>j9~#ox0b*P#=B%o- zSUEpX7Z|Kk=?c9b=VF#}+|ehMk#0(k3m+XSI2Ikl=ka>{A@!=fObACG{Iqmycs3R6 z$_7R`!w3WNo>1rC7I?Y}=DsVfo?;2avcTWHo>c*?4z*_jLBov&haC@$BM1oLfSaT% z;PnB-+u| zR2`es9zLt4fX$huu5X=H&KlYj+gjBuw>MJ38`pEQ0EnQvpAuZ@MCezFHmOt@)I?T} zBpyaBSVHFV(@vTM$#r-|2gxJsk_fyuF&Azpfa;`^L3c|h1Ee6E8*4k(PRrnvPoqel zFNyr2-E~_dpWo2Rx1vZ+x3UNikBMVH&S>uv{>I^$s>F_Ptxv;95;2uZZA31A&{Fi* zGmh&LP)J5~!)Aa+^OUPo^SI^w-Vr%IP(T^vabou9KK$dz$zn8r z(qez{A7VCr&!><&`8|t=qZ%pvuw2aI_{*MvKYHg77z;ts$4m}5WvVk`;Ibh`j9nEy^p}Wl~0A3?qj7)O#pexMM0!9Pr z?1z_11F9O3&A<)s?@6YpPlg*43m45vl!QmVSfAiB%yIcnGloIgW$mM@Guk253l7 zm^!nGwZvE{%$_g-|JE6@V_wRReK{8}< zyy7YS=b89nLv$oU8(s_J`2m`-d}t&;O3(AFgq`+Y6L_=og(&(N(9?{7dqQAm;_*n* zPO?;(DG*Oe2W30i8Z*aD;=~E&bhHiIt<&y`G@kVKQ zYtSOqwqqgu2h*3%bsaD@Y(*k<@R{vdSF`)yZ05oqycOQ(JjnH~dp?*N(APY&-6pH- z*x>oR*ZO#VoWgHh#aM5&@YSv$v>g|W3-ab0E@|&(bC1PiyeX*5d&e^&{Mm0^T}5cb zb33vDQnb(UF;^>bt;iqVFB!1BGst``hj=65#NMV1a{c}iX^^{fjMRGeNd3LH=!Hb8 z=L#Q~1w%&tPrM>Mcmoj@W_2{0WKt3lM&7kU1b=tlUkImmGljsXR9r{6 zNvIVMP$}T?w>@{abb($6DgsPBG@58rGHecx7y6R5kV*2op4r0^19 z;Vs+E4EU!-)JnO9_%ao4H}!>b;K>sJxhEC%+sx_j^#-t50^R#Sj38y(>s_OyS_E<1 z^_J30OZ%E;^CADSkXlN7$pf+bq=^>}xZ< zsP1bT>+-LAPg`%X2hF#?50yUuIR1kQhO=niGduPRP;cP;0>v77&M7#yq-pPxPhTr+ zZV+Bx#;TV4_(-8?QcC>{U6hpTEoNBT7K)X3b%_lPBfc`PLZ1)Y(T;K41wD%_mZx@VGT{mO4Q{eLs!Bl*edeV#(sU>0HEjAK=})IBp~G z7kDl|fz{*t^%<%Y`R%C7b!(E*w%Q!~?dLcEX?*Umk+D=vlST7Kubjk7ZFcO`Qc z^Xaofj&Yuzjs<|-_mjuNaf3LiT^K`u4}G92(`yOz6^9#$91{sF3$1KwhVW) z?^ILUteQ;f@q!xOjdXA|p#<~4B}#@YLJOHK_)oZ~Avdc>+i5Er7fDY|eGr5nl4F}xu2a|P zG6%YcMV(X5m;v!{@S#8kCP^RtvFGi64t9hT-ov-?391M*Psm-b=YzM+O2z z;()+plZMNsw7b~A6L*TcpfBy)F*^NDffv{m2j+?%*V5;&Yq%Iq)zbdI7Tr}+*jL*H z|NK7*dHSlp2=o3gaZY!aa32V%kiKs+HxgpO8~w;uL|>p6?mC)5eA-R;1JXb>OjPXG zs|8pWR|#ElCNh*eWgH@2=>x&sg0YFL`nUB?E-wmut-Cb(zm^qO#Osnd3`hxvn-|450TV5V= z59ljxfPo}I|5FfPvA~)__Nva%cXu!B(4wLLHy6dSMvb8QKin9yWT__xMgOb$JcQ{C@v`SP|el;U6gjym{o_5GJdwmjMPGl3pVqqma@KdGY_z zqPZlwi`!ChXRFlxl${ScngK&B6+y$gn)EDN_O)3}XWSAKfAIPnL1wV0nCcY&eB~XT zT(Ng~8$v1-yPTcfZl}1t;u9Eo4psYG8_OptuMz zjA;Y_p?;aDhDU?)H7T|fB>bRxavIz*MYog6Cc*H;5h2?-ZqWP~p=z<8d1lAiaf zltmNQwMVN4&O>>QMSg!x#;etre=q$SIrLH`uFhI3EWS}cczNlQ^Cd5a9nb|dqfepf#V(-^?PHQYwASK zxNxvDK457vs0&YFpv^H4fuAF@_;2-cOn*fW2#v_{cwX;zg7vb{?F32iWqse{F8)+F zY_$^NM%73DG@AW2AX3pV^x7DF{EN|%ifIEttU$4goe~~{kSiK|%i&*#U+KRdf{qQv zU4!LlLd!}y+f^0lrz~NO@yTiO0h!{2!P6G7_>iNciPw7J&))ax;V_dv9oT9^;e`AX z{uv1n3@Dc_tQuNx=$E03ocAet>@xA3#wFvji51EN9l2g>`{R#~}5f#q9lstN-@N)+%XDfWECL6~B zIcE%PxDSS;riG3RMJ~DHI+Ld;m2(!yvWhx4wLCauG&@heg5}t`yQWTJQ|^hISw>M~ z@8d=(QhF&%#{+QkjefQS{k6BkRfchUzENpW5SpFUl_V> zSV9sMOVZhBL_Muq;ug`F*15e~IhcdH@)Hkt<*msR2knb_T8AIirm3FGpSV{hwzik=&^FkE$e&TNik6#nTkCbPR z2{KDj-G{br}kY}*{hewBn^J#TV1 zIjCu6nsR&xs9eV?K)o31_1gatmGi2MnvDKHZ79VV{V9Bp2MKL1j=%o@;&)2%AKnt= z=7vxCDiCsXKKH-ldv>$EKhyJ|_g-t_K9Ks@Kc3tYiU;K~0^&u&pjVS)ho&$SVH<`B zIVJS}y1ibQkXX22b{ZG))p1Asvy4XEF+leAYtxj^=8YC@#9wnWwc4QzhMZ+{|ND8y znEVe^k}vgWnJot%9YD1wZ6q_mqv}=*E zmHtva^#+xL_Mru0{2@4QR$yIRn145G@EoiC$GAqAv+u$S&)^j()&qWMb`ev0JBJa{ z-HC!H{TT4IGNJO_NKV2(34sakGWq5>LQuag)&WizH1!xrPZcz&-hj&p)Jyy}ICFzz zzlVbME0tvppVMj-8@^hZ@c6(2cBJRq6{!WSNcU8jdUHZ-M6PE>4mTo%HI6;5t&<^5FD+RO2O~DAgq>!5Xm>z`M}SVhV_Oo4KokZOeaf^x9O4ca>)Ifo1WqYOWp8I!l?fnl?5+$ z(QmBf`I#eJ=8Jt}=kaorKe~JcKf$!X>`qdHD>cPCfXJFD|AaVgf~TNKku<{*_<|6s ziA$D%M2NW=UFt-JTfpy&F+zh21frLDgh>Jj96;dhNS$Q^S>tJa;LmK(&@5wE zr2)TD8n%|5xTQ4k40~{q+K_gp_FxOkZfz+IIE3SbOLAda&rdBZQ$!h!R5HpjX6qZG zgFr`D6vfU6tYc@?wjY?uaiy=6y7>Z!cu@;$WXqnz+t*|T3V05*n9V4JA3p*5fd`qC!0xQkn21`jgdR@v^xNSo zxG#`+68rjnpZc5jQr@tp-$(8lY*66&F?chxRj}Jvj}*H;3mAnY=W5*5vGh3qBV%u;0Ku+h{&X`#Ys<77L=3jm@pU(I7fdI z2B}kW7jL;rq*NJPU69e!bN1}P21{~El-ya&WYX^Q(vURAQ|_$Fjr5Q#R{5%egH`VC zdioC+(QivTsJi@-AqJPJGZY>oW&!F%lh*aCvvhTzM6#N9rxUlJ49NgDm@ek*(iuHv zGLTj&S7#6RqO<|vXQ6L$9bFIoX$r^-NVUr(w`91X#1w0UMj0Z*M2Olj%=-4Zik7T~ z@s`P&MWJI(4wHz{L-9|J_#LN%SrkfmUxA~EL~;p~7bF@7`gVGoX@Ceh<96;OYC-$_vvb&q zEZ!nzk5nu*liIeKxoq^S*3C*`gYe?dVK7uQu(UT4!rBaVL^L6+`J0GpS3k0C9pzAQ zEspU44v;6AR49pck|S_v5MD|(0kj_4t?e}BPno|Ija}f9AL!1t;t&4X?&n44S^xgR z|NMpDk8oM(vmJ>Jo*JLrbI$hn($`*dSVnz)UDpU|WH80r?1BORyWxUK0s-#Vc$=<~ zopzuO2xF~^96?TK?MB&>H)^wOB#C{23-KcT%}tFXXpM(>&3=s|JFT@#@Tl3ky>=)a z3D4VOH02Os@|~r?;er|o=pvAWSJJQOQH%oSmqT0uNih5iJxfk7z|>Bo(X&Y-=C+C# z^rM9&eS5n)l$WtXQe$bUw^;12ah$cYgvz(N0@M&cgF!4tp?3%(wP^Gbwa4~xPf`QM zifad=%TEvJ9c#xz&<>%(U-VU&m*o$qU@+{w4dEjbUpme}(J*5X0t#=+PLFzjRTzF} znzuqMmaL47p87zp#L!YEE?R`16}zxbQO}R_YX%`cKiLYa<}<~TjdNX=iFus?{@BaX z@*G37R5F#7%!ue|6pLN-I+Tm_+k8{-&-fSo4nA$i5;#x}0YWFtA6W?O5i&tb0f7_j zGaUzrr3^+BLRYg11<}Lo%Q(2`WUSO}<9RJUBge%7Vp$+3o+cNMPeAnYX1@**Z2jE<-|jM2CljSU0J0mRL8~W9-zG zGW17FS==N^ynov7X|skl2;bYb1nPL#vaY(&pMXA*pJ{3)Af(Vlh@4=HfMh9&kEMvv z6$}7_B8~xuCfK(}B2L5MKa?|3Z&`v|?X7W3jO@fB{KrQHES+H8o^h^0Ni!~${7vHhREC>49qdiOMr-8ph=J0|3Cv+i6pcMh+Opzx@ zUSEe@u+{r%KlP>ZLA8255u(b?xk= zB`%WWp9Yyd4H^N!xaLI*zsZxF;c6b-etSXc+z&ZCL8+~lI85Cr2Rpy}w* zgwli~&SIx6)=g}<$w2QpD`T7G0b#`mK&vda1b?@79PY-m?J*keMBAE++*<%;wkc1s5H<(dwir--9k3vRk3~}^L2D-gAxPsDA zj!fK4n}v^lv-;W8lf!3~;qglv#}EU90+i$eAHG6a=SIqcTXu`l+f zUF;Onu^u2Wf7zp;Bm8Nf{c7ya#KAl22{$F&gu9bAm)mu86u<-|EEf-$$`en~M2H+Z zLrfH4I}ZXKbf=)s@UWt#;g)dRp}+$o|nu4I6`=2JYf z_**emgs5V_mX$T^0Cqsn1bfVfTsQ=L;}F18g9s}e*~_H2(`=MYG;$P=7_7+XP}VUZ zriO^-oa|3cNAR`N##jeE!aL`zZl^tpJ3@~=Mp6)O zd`X`I{*meR1_;>}o9K>Zu!L#6ZHp*GkF;tc6KMwr=mC371Uvu>m*K1qgdT=W@Y@Op z2@(H@qX7pnKuyRb0g?g)G&@bZOrtqsu}pmYRNZ9R<4!&S{!z^`O+7km)gU?y;UDk| zKLpDT0>thrb1!n{-Fyel{R5w zfe267VJXC2=MsgV7zt=8sc+yMa)qaY$brxfB1aJeEm<0ar%nt1056V+v%z#W(ryPq zroA@%KBWwAFDYWVOJnf@ocP?8PlQ5zrtvJR5UBG=#~h;_P|5bWNymc~8zU2Q^nSTg z>Q}1^z$7_!a2BU;W(nGSCi2 zz#2VZzNs_loI~sd1HfVv3~68>%%K2WH4@6XWE2Nkig-ypERAVK79c)Et`MFan7~8m zSa!Oj69LjHaXR8zG4Iz}Ad|)U`zDCl5RnvwY8L-L_TD=@s&Z=^pSAa%Br|(YNSl&A zlSv_!kV$3|AS9%aMt}ez0YV8KLhneG4kAbqL69azK#(d;5meyV1(9O!iVZ;}0@=@e z>)8pI37HbV-}PO;Ke*0&&MT)lYd>W@Yu)Q!_u~1r-PQTq1p!#r%}LljH@|HwOWT-x zeU$1g3*oys>>vKA?UeD`p2cY~Of;VSfVinKIjOvI$VBfJYkH$J#a?;KwuRzF=WD_s z5Bpu5B|>{tu%uV7kEUUVimQj<;g%Fw;c$-`yS*k`U9q1d0qh8F;(c85{?H-Jzgc67 z7wQX8sS#Qw!kI*_e#4)2gNPPos58_x{|+dEi4MPnn^-r5sOsL#6oSHED_{S2KGEGt z&c6n6R*ZvBaxY-+XOehOG{?i%S7d?VWpv`2jFJO$^&5W(9pCG0O4rcPxNwDIC#8~H z8B^u(+nMwB2NK=)RgyC!F6=5@hi2A3>^Z5vPBV8}T6ej=?u*f=$!6mV7{H({kqI;|FT zGe}QzR0}WmGY7{g^-uqNpkuK?BK%5>#5zAf)oxgAq*TIaUT<>Kq=+|(-P zb?*8r>bRQCjIO_ioU`J!sBf@p)V|KvIjFl&Yt=!B%QsrBZn#v9)TJ4r!4fqrMA!Ud zvoajkoUbzP*VUYpXr0{UsxywQXs~ro>&sQP);XyslkqmT%4Lb*!>)T!H#XO5ST~_^ z{E)@2)QGP%u-S#0FgaV_aIF1WbJ^bzZ)}M#&wr`?+ML9uR(K({{^4vwL6>Z?J*HT@ zwQgb!b+ubM&7+C6TVU}+BrpS=R5-X=q8-D=Ony#T$oU@moPo$DmSmw&FCP_CQIw$j z!C(0S-ZQ;;ob}^q?KKYH8&|5Y51krtRA>Fj_PB7)>#BEOtAARMk7n&F8&R)@DtS@I zRaCa&RcyRox~hR~b_=BBXIqmH!_2zdk?kwhLYt$)tc21ys)jZ+2prTsna3K~<*d+I z{ce5pIIL%$W6SF5nJH2?byF&u51Hz`b_vfku-Nrt<(=8}ZD(bX)@){JT}?6;*VS=N zGRo!#IjB5#W@2WncgtnfyS`wcCFiWtc*?*IO{(aWpuOSAzlJ^v#VKW9F=rms6l?E_ z^Qt1sb~bnu9MundH&{ix1;ab_*Nm~QvMz!$sV*2wSM1@sAd>L@^)n5sf5FkS*Rk24 z-ufkgxshU@__hHCS}IBsG=IADFH5n5#(Iuf#MRdI%=k9DT-ReU9MiaKq!Gm7&Z~C` z5VPQEwk@Ms^#wqdOVv8^tbwV>>|GkKA@2NE^l#0fl$j~j<-)KEX}2{DY^yM-yIqQnqd(8Ze4vX7ExW9bAcv#Lvx(h(i%jAj*DlB4Q)&T6SKdrP8Nc! z=f*2#5BvUyYhp)j7wO>|82QWQ$2nOH1hV_;YF)KA%Xzg+uGJt*2uolYxR_dVRHU=I z7Vj6~B?zH?zGF@>g(>bKNsdZc{_-xb&T)6YZC3j6x-l=f3wZ81tXo}wP`7F&<;qR1 zTs^#gO{h_gv0Yf!c}+@8wOMHyR(K;pX;Xh{C$U`rV+HD=&uL<<>1W$7YEo^9oNw@L zSVyv^vuBZOl}z{MztxfIhn7vP7BQtZk!2H#L!{g`w8ZzOmFy=r;z1p#jih|m^h(e} z?~lthpN27rOGTc$^|yqRg<03Js~^;Ia25g9vYJxMssCEITCss7h?)NkU`xU?_-M3s zy+R{iHbCF>-reFg_gLi%(mNL;yTi7XYB9B$pv%>mhR)lix(n0bGT1M)khuEm%*sp% zAU2okDT#<`x>`|jf2difxzbbKozuM7g$b z2gRHogjsf~8P~X&2^&>KZa%Mk#(>^#@j^VKcN-T`Q4sI{wVuC1*DMlZv7eaMDQfrF zwMxZ92hp{f1bkq77Fp*-2=al=nY@)sTCn=MT0<(HNsLNet~Jzm!tfs|3NXLEBX+rf zAm%ji!gSp&R&SiA+b9dCYe6H}zD^zG?jwW_`++&m6txO5*+uh* z;N^N;;dU!d8N~aI-sPr1jF+<>VLp(sduoZ+A$IR;lgHRY=#RzBUdLpCdJFo0i$grV z=65}&HoY;m`Ae=mQoACs)XV&iFq&wqC1UL^2nD@)AU4a|N&cNNS=z zX813=zJ2EWba7l5If;{AeoIKm6-xU|Azu*kVUA(Z zy1#_1@!>~6oqvWruj{7Mg-58axqYddHayECg~L6CmZwj|>c|yG-v;6d@nvox*RKxea#!QvD@s7b!WxHjiLq;1F7u_FEtwp< zbHZv*5lYP9Tn8Umrpi=hmS0=?+N?~tBF-~y!r5Tm4^0kJkxxl<n=G7G>=yNB5>#ij)O7d^AijV(5Z(>nCK!YAzacQv9RITVqp6&FpjE2+;KcbQYeA;x;@t zU`m_x@XWOLg|vYqBPxYY$mfgZ6p+t|JR*@@OJ<9ojchYt5Xy%$J09DynrdGG>4bN& z755Cj;`k=gL+^|1CA5W3e!xWqBZ^!lftrz{g;b$NmXd_kX%>ef;3SCUJhrjs3p=&F zyr4t4Ac#YRZSU@ZxgU}tI&yFMWiRLl*6?>P>A+3ko)IRuApHjKY4MSWH$!-zL<{-M z$AY;1yRJGRWHT(^qZ43yUYF9mt}<5q0p~}o;rb)a6@|Pj$qV2%W5t__ZTSK2xjf0& z#29J6oeamfc+)0`lw_=nqYTyPFio0vb$lxMjx0(OzQBqS?XyJ zx0W`WM5;F~Z5GrUhQm2BJ7%0`cdpbkZQFv_f~CC|1bL!%-QS*`-M27`(wD^IpwfZC zrN?1IX-xGr2-=W`=j$Zt$Ts6D5g(_@l(ZcbDS2Ud{Qcx6X%-y1Ch9LOY8lph5{WctyyHEG**@Gs%4M z3gGm&6tk}x5`}R&{A@pBd~w&)ziY^ych)~Xq~PbrPr<@We)Z(;{QTkRTpyl%n{bu9 zCBBw=oY_#5lhB#p49`PRujOsO<@ryc86F^vGsIuXXUrb1E5@v`SnI*OMD6WG553tJ z8@g}`(0%m<8D;-U!;ezJD3cHO8kwFcxLnivi%)#FRrAKaSpL#=^Pxq@-sjsv1PrNc zH}P2mpYwECL7K)qC#kc#itB3hB{xZ5l4Ljlqs%->{7_>aq2%+YL-QkVjO2M`s{(b| z+7Skg#fm){lyBYA6Q zFpqHsC^cFfKKJn~bQT{yOPDgyxqr<>ECxEu#%5G}+D<6d6fYX~Yht|e1j#PPh2sL@CNUHlVEFRdlgX!mi8 z5p*6wr6UIU?d_b~VTB;y-|tLixNh{@5wPQnPJ-_I`Fx%K`0=HQLQ)t^aP-Cv5S~lS z7mlpBRvDqvsR!Vbrb~?I_Fm%m!aSX>BYAISl`wK1dEU`0N9#=P7?aFEZnhRPQ>q&x z^&B@I8vR$!HrLC+caV8aVrnx-?SBvx18X{u3eswOcjA@97V#I_z5oH|vj=U9Z(H7( zoR3wvN}HiqhVn5xw??4m1CV&Jn(d4^E>>iv9mc+JBxd3^hPl`JI6B=vjE+RcGpaSo z&A#{?phLzZAi0rni_AM2Uo0OBjNpwzX2y;bMH}O>5urkb@m5INR$H|d6@Q8=v^=C< zTAEs9#3g01OY)V0~C6vFz z7z{g1qvUoXu*Lv09Xy`@3eiU8j?^6q&+qqMq7V)j9PFzzhV(8WQ4qpkx4)ZMZ*(WC z*xR^2zIZZebSXFC?nJpEo`$W6_!wc^rOXg6Dao2--smE*r3(K&s+mfi3{Exk0yXX$ zHeAt85samc?r;`3v)Zub>hIuLIYND->MDFo4wE}Gm3KBAf}6&Wq2c_9tId0MJ*WIl zV;1i~?jJ|Ya?T{qYZevDM+NA)5{=rRR_mWURurQ8CvH{GyXEFsekmM*l&eLZ^2;;2 z=e1Ju1Cy1@hhsNzklru~{&>+7r!)x&*Jw^Tq^|Rw?d%o|-3qCJ zx6h5gx{E4xK!O~W}((fv|NN?zR`yszY?MV^b<{*JaPYef3^=yys~D$f#gb{Ee; zl`6Wqs&BNX3SSln8-(W=KabKuJ)mX8U;Ir^mFF-$_lND}cv-;Q@h8V4`)O<_~q zBu+micR>h0(ty2^VwvXLnjtW@y{9h{)Gd2E$KA2Y_%^`8os*gY&ARc1QCu172#q>R zmW!A9I9LN2I4F`VCpz9TkLL$uK|4lef#w>YfT{hb%$tgKG1h;qD)}hHF~bm5mhJ}j z8q?T>Qp!vzI3JvbG&G{2hXxy5?Ky1FtCDnMx)*j=wYcQJSr8)j%-FMdPK^IqPIR=S zsQ8&WVYT2tt-G-BxB$r!IBmO-d^c)Uq5s~N+sJwc%L?M#;%v4*;xAFua#UXo#^^-~ zlTC7zAC&yh{gflwAiYIqxUSOoN!k=6j#upmKg=EE{xis96?r3GA5M1k2A1*bN=~Q* z(dfr@Q606ohx*@0x+9KNB^_g~i(iut9g+fOss_x$9OGaON|>%l-H+$=Z>yQB!gH#X zJfxlD7vT~r#}znOt(tFLV-4T2ziPiaWem%h+Zja7T)^2CwP+%SJ~Zx-)BK+X^fCh{ z=V%mSjzEoOI(#n!a4;W6Z=Bj3h<(Q>@PlWWxj@!*$*!8Xg&YllK%G1vG_ZC6Hr_b|0;TknisabEUM{Kp z{Rj6@rH)dm0wR}Y4nEYw$W}NwH(bYn4yJ1X8HakPgktMp|6t>I)xp{8NI(1Mg!(LN z4|0?B=XyxoAKKJTTWj!!d7<`#iKYo^FD%;Ah%^}^Qe0hB>Bf!6xPa1WG3Rukc>ksM0V@U4lpCsF zi+L%g2r0>!VBwyk$`DoJQu`S&okoQSI~VjCubq)20C(oJb_TDF&p1o|7^Ed{Xm;nw zXFFQlF1&W|=}w&{1qX!eej=pP5zSbQu9 z<7vRJ)1$;qS)U-g%#Fu~pFB^e(Z?(>D_s>OoHn^J8)n~a${~2?$_i-*^ zZkSIw`#x8Ix0O!55$^7(UIo8l+!rUk+!%}F!N*)ZOoHCrYPZJ7fGi5d=%lQX-p;5L z9|%wu^20ZF(+@B8gS?u;GO`1H!aVP#4(fv>jl6R7_o?KcJlMqjRJdB7q3ptyAQ%zD zhw{mxi~aYLYOy9~AHSV|ID#BzlsI*;cfo{YWyhzO1>m+zja>&P=^n86`d4pezefL| z$9$HeBN~K4(7Vy=Nu!+ja3ES>%W|r38f7F)$@lNyixb6Id}+I~X)RL_s?|d4p7`g2 zsS7lw^grk8a_M@VtKPy+!gw;A`Z9O54{D8Lv_KaMBu{2UA0dlK3CHcmFf+l8?OnASxyU9eTM1L;=br!3&Q|8iML-t;^4>R(-yAt-og z6+eHM8N)=tJL>jV!H@VISYxbeUNATh$*TB1+`Ce}lR3imLgbgmwzY^VCPbJSX%Hr) zBbICL2zpq|VBOI)+`<%u3=w{prUR<+xZ(^-*s8&e(UcpO_iqaW3>qAeeUmIeL7M^a z_93!Ve4qS$2M=1@xhYIF>^enE%Qc#y_8JXetl_y{_^;%js!q#?VqSP>fMqJVv*-yv zk7SFhNH#Ns=?(5YZx{&fuRL!6?+yG91F}Yymlw|~tC7Bet6sy+;?^PViflS5`^GV< z6kfreEH$S9wGpvvnE^u?go6=fL9mXZi>SJzMO{6de1kjBd?K7nDvL6GO|5;!f4ahwRw@U-2R4meY@6CN~WwTJ7MgxWi3?iDYp1 zQ1w%c0z~oH`ag_; zX8vDdL|QwW?IK3h$?4X*1{8@7Hk97Wnyl(wTu;Qc{iN)uJ>4nvgjlabrznY8VQ*)$ zMK0k&2-iLUi!k7q{0X`f#Omvw=?9NAzR~CJb=|ph!hy1VeY(b69<98`WbmibL)Alr z!z!crp%>IcbI32=sl0z61SH?%nEvcv_r{55r+*X#`P|DbLj$?KYGrt!QorzKV6%$_ z+dE|N%C4EA7yA5BP@LO6y<1jUOmuV#IhSuHL1Ihw-R(-bdj_8|lz9VY=x21JzOp8+ z`aJth)A1rZJLd{s1b3Y8n*-Fy%h7JZ-6HVU^&#mm7OwG) z`uLugAYTf(#mm_+cs3#Ti8SpjuN2|Uz0`j*HQ!Wk=Bk^53)!rqtS%jZaJ#q=J4Mh| zk&C2~6haXkWAYBPUyEXTe&iie+P>4A4Gau6+Y9o(ENXf4C`Nb7d* z}$?JA^bS*?#b#WI0c?ZB_hqywLAus zH-_#MVWx>T4(y6@qY*oncp{Cc$4CJK8kROgcutB}K&HirMN)pI%Z@Qi20)L((wG$e zS0Srsg{H**8Ka)SuN_kwn!2#>u$26m@HfbF;(O#RjJBR)n5j&mj;yNrLwuRM(yd~& z055Spx4z7ksmV9V8`Wb5>7$JLSxP10G--hw4`swIUTkU?o14YNeYW;`S4}*5lLY=E zhHDs(xyC>ouW6GG>#t0~^M2=U^;%{;Hw>`^T8(^w88J~uaUE)i2U&ZI=P|u(!?|KZiK^p?&bi`z8Ze!V$RggkiZSo z@cOeOw_lwCD+Qj9B?tdPxcjfQJ`r!->(0y5pA=CP}N>@+3)U4P{}eHlq;{VvT{;JYpdn{|&iXi@#I`OU+)!rD0wEZZQ!Z$Ij1% zFPE$(&%n8*S|gV)Tu%_Rt#Y-I*)3w)x(DU{jTPKhx%=Bc(`s0&ef0NavrwZoSJH&W z-@C5}?QM~m3GS#^g4j9U4t!iCMQAGZ+#-h`#FK6O;hrmPl%Ba5?t;%nKmwe%8O>$;yG^q;284)x+I$=Bb_PvHvQ{fGPxGC+=VHb*VvKW(=knqSBp_F{{672U|O zcujiixdznJk9%oX6lV4y8!B_-HlZ)m;vdikEoN@r{KyOb8a9J9t~uZ%Yfm1g->= z%G5>Wg>5>nh#%7t1^oFL=1a>+K`OLgpN;ZQ^g9aRS>_0mHmT!?T^lvRs%WX~ zuZF<#;J#gEQBG(Q6A$x{bK@z~(09~vf90`&{u$;x@~Hv^=@rI!vp3T$CMujOty1WV zn)Mny)d%*JX2(~0#|Ngomr~UkIX$x0u#vG~{~RTy;5*U`O-JgCpSuJSK)PbS4-^`G z6m3b?a*peu$QwA!oc+m+ypEC2tnD>z7hMMiCkB1boW@+gu0KibN2ycDX2eWb?H7yE zvu0^p4n6A<(2sEwe5jdMhIO(%K>>U$FTOI8Z`K#H559L7Gq2`)wch-Fm%u5ZWY1j7 zT;LX*=r918B7AI@)y1uYEOY5^#=vQ23i6`dWz8hZQQXGtD~bCXk+!$p^ZmV^GsixA z*3)y$F0Q|45b00`5d)U4k6C&Vj|+upn0)5h2YrK>-6x^Xqv9&k(};W*^nR4_g72vx zzBV-UcGM!s+3g9$HO`h1n1lVuD%?L>aPX!27qs$mLl9OK>OSX+6fvFB$*yD~ws{Gj zDD92k*9-13E4bsx4Go~*hZzE8bwnX}M*#+!5MaRs&o(n%eQ~2_RB^Aa7me4H8O7w+ z@q9|Cnmew(m^^vk%xjhrKC{Lj%;>pXo!_@$5#2A4g)lEMk?6<#&>kwDV$xDqk6(+o z5aD`rRY%@`M7c)OBTn-Z`6`er;5GB-^YaJB>kUsCdL*TP(Q%Zt?{UmW>`=@(bu>!G zl7z%RiK56qYHZTXQ>uT;c9TUUl$MjW}jP#AytN*t?Dfv+Lvr7DX8JTy`=3-f(Vth7#tF9m%x^f>*&fY;T_0>wI=@OtIS z{DOk18&{MHJ>A<>LeUL@RFU6z3s!EDuB8wj9^vm1ETrp@p_?v{Oz5i<5Kdkry^GnX z7jR$OkNUx;vQ%6|8eGE>@cYs4!%_AJ%mKY>d7p@&(iSaBM@7+`a5}gHi|*7ZV7QNC zJ?JAtLw*daOp#Gwt=LF{K~IYmaA9Qu-&u4`QgY`?CePVV)1}`v!N<&P?4t@oBk;!J`$UhT#-v97 zLW9fu%5>1Xwk7H@O}S?Hk>d5VQ_4_+qgQ-+jb>ofl~*)BJ!L2l@Zg2**#~yiep4r= zghPcEDbL4!cgDc5%d~KuE!X~5E}Uo7=zBcrnZ{ZS?j(aA%xp=9nSP^hDV0Zl3f)w| z75k0rIOWS;Q06yyX&_zqUd)s1Cax2{ulW7A%ve%4Md5xUk;UDJ4jUmp$sJ;yvM*l} z7q1l(R|pFQtq?zGBl*5lJU=~5%XQMJbf+f0RT-vx_F&iFs(9aw#6CJ*pRW*WHCYhp z!jt6A7+Lh_mSh&?flL%jKdVtbZ}iaY)oh$U0v~@(JI&)>-Yw;tc3ipMTr_#>s?WA+ zkT?7a&;L^d%_eW1qt;Gxlg>*gCYb3W)%fvi=vkpt`6#Q#row+!&}e#f=96%WlQwK- zTE*thp>~S(bPp1l=FBc`u=E;ZGG|2o7JZ256NlV-l-EO9wg@wmtks=Z-nG1gaiTU{ ztIhCB$u$1l8s@g=25Z0UQ^9O&J#6AoI63TbLA!mJWNR^Y!LP!3l+C5psH>vWVxog8 z`fp0jq)g%OBZoDUl$w~<$E~tH8gkihb0$}z)bu-CSU5UR7qod!aM5nR;eNuB!4OTXEM?E`1!)9L#U7?e?R0SzuF8bd?nWLU3M_8T1-pABmjO+-yXd#R#2|)q;V+fM< zrw4FyQ22ys!h-mqgz&cJkSJ}auLsDvfYi3>>72^nFFMqmmez_bDjmQXTLuXMp|kY~ zL7oZbm7(;162=WGx27b;CqC&LVd4PAz!g)UIeCbVU(Fk7)y!aUQu^Zm4uOJFqFt zHvASUBK4o@#oJu27pIi;;S33Zf`7(0gEfo7N~@!r=dT2nEC2(-s#o#`=_X z9DHMs2Tt-I=7GaEjq|{34q5+_1;cgMdEnw@2Rv}N?t}+kGmk{e3gL#UJaF-l10Fcs zaLNO(`G))>^I}FKH?nZqn@QPmpERdfw>Eng<;;paB}|cidM|XFUc+P zVWMJX@DA`gvwD3VeM^{{9agJ#JaYxtiB3RpnF~&kMyymOciV9?x=+`PqLOhl=j$t0 z#rVyP4Ng`owMl+$nJY@=%Av}gA=`%+s6)FcKS@@0_uU^(-cX)Y^2z7iWo%8kOkw?y z`RqS&!AWQ5TyW+ym$=|H%f!91vp^hi!NswSbHRZ$&ILzRf+ws8fPR~cTyT=&hzky% zIp>0l;no1_lXwPQ;DVDOcDdki!vPmu{K$IQ8i8w7#|5WcFmx5@F67227J0BZG7-sO z$ihVtizw2gQ8G9XyJT?cL&YK1To*6%6cu75fPWj+uz(;X*>)7xa z$KF4^F$C{^f&{$+d)V_*R+JE1!&R zLoT#`58fL%Lh=`4mh~~~6ehKn6OKcw>~X@MYm^esq&7|o2eGFt-`XGdi}Jx5=44K?-Yl~I$0~>t6EMt-h{?E z;iN?aQaF6|FezN}#m8j>tOUMyofj@{w$BTP?;Y{NYbvZ|GB+k%^82*ot;x!b1bDT( zuBcBB?`PVlXU$S_&vx5Y8mg{5pog)q=PK3v_GPFw0|yqy@NpqbxN-@0X>Xi$s?;E; zeaf~*s?{<2!b36t<0q)qIpoh-efYlPaSgbd8P?4*>`iQ(7mfj&q(WAQDPVIKQbdhN z5S2?LC+w_|c;TdtpV~L%`HzE_hyK(yp)zTCr}cS!8WesP9vV93YxUqRWVo`uy7|ws zYm_hJ6hL{x-prAHM-P-8t?ceMU#V=f+%zw*(qFAExvo^F#g9>!^oky!mqE>W@)6F} zv%1Cy7cV;GgM-x(9~^ayWp`!XOsPzPr%U$G%(BrwiT)9apt&Dx$ju1~Okhegc8pY< zR#=qs?knj%Wos5%GqJDc%m2pRM!gN!xZCh$6S&(oUsx|#MfjTXOkL(~6A$OAeekti zVLn_75dEwRtXJTsQ|>lt+ZcBnZaUy@-`hvNgK%bNZR}u250r5rAI0o+Bn}04)YU)9 z-eJbmzrQ^_j54QPn#_1-J(xA;u(L~v=fyd{TYTFdzZ<@^%kTc@x^<9s0^S{o-%T0x zj``iBzyZM_a^hTj`iW5aXWTxPUxVJhk0lkT-0e)lCi^lqlIQF=FO@(}D#djfqby>}1l(oR&; zYF@A_hsFrb6wlEAT(`c4li)_+d5WVdcWl%9S-J=}3ZCkhlj)d-!;BDwtGe9G#D2QO z!@BQI=chlJv4Q+Ay}&raAC-QETK!b&6QO3P^`@*N+;*|gLQJ!xXbHC+idIU$B~G(W zvld|-@^GI8Is9**1p^KGEdDUm?6U5FjkL>*MKU?6z@ea17^3KO&Y&K3#4%a_c$zqo z%oVrZd$ne(nH=jK116OA{EJOw-e>X-^BrO1;ErKrBN_K`PZW@<=tz!zM10}SrNKQ@ z5Tt3;Qs#;MmZ<+1ug=tKs?=5R%aYRFBWINM z%P0+icq0H5GT7{L9^ z_7MiqkkkGM19*f1*zezdgaOp+-+qJv*!Flo!T>OjdV~QuQayi!0X)J0{u2hkqQ+SV z=5t98E?DAlG^Ppn(Dd3yMB>25w?%!JyfM)@QkJn}l;VuSqHsg#oRcNvyyW}(aq-g3 zKo>OR9-4LTP*iTdEqL!lV~z}MfbFA=GJn)ztks;W)y{FQJNM9fITE5{mNYQYnJ}F= z)7D7bL+hbBow^`e0k@efTB}tD4d*sLBwoj)`{+@1Be45h%=1ik3%9`dhH`cy!{xs) z-ren5g2Gp!9`H1s$;{#J{qsQI&Qv?shU$JvVi_pegi`3J#F0W3Gf?-$lw$V%SK|)l zyF!t=9T}BD2SFib<$RpMXrDN*P-WoM4`5?%hF@TUGZ8#85!&q>t$_1_B0-_(xf)oU zFNSKGf7a8*Gt1KPfkCVm#g6on!iO^~1PyP-NOq&Zh&d|Kl^*}Uy>%fV1f;*oZf7&Y zH36}%i06?5ZbjQC$Q57ZC_oW3WR(IDKICxytEXqUk29l44y6&HQc^uly@rFu!H&ot z%X+sfZtoUw;!SoAn_ZC~5Z9>eUd?T{_@^rb#gkczK_1@eb3B^i{_ADKnD^^+_-gMk z>eWar1zJ9|qs3Qm=Wzw;&N~$6yY3UkX_SG(--9iXlj;%ZOs$V}V{&(n^WZ)RP=+Zy z`>f`uObo7Dfh@O9zc2dVxu5nm{Lo4i+^M(E4n+N4^9lBG7Xpg^$8KZV#R`FO_ErAG zTzSFH32yG6nH7RUJLqY5fu6}ED2GoU=>A2~*;S6U{~j6}-?$i{$~&h=vE2XcDRwH? zuCi-DjD2;$SKOnPbr<*-PXVfhXEO&<2gwjzQrH*jwc zX$p$dZT%T1Yj3^sKwq$hq{WYC+quTz!Ub-0wey0Ua#up3;Zb%ZZupWn5}^2ivf8IW9uRL|rQtI!eWXAO zWbGKEVCr6%RSZ+>XF}jp=BlK(C^4dZ8{^m@H#QWdsUzK^ELyWR(%r3r2qV1eli4B1 zE#TN$b}pOKD-V5gBeIO-v|Id|-n?R2redInSNdE9mcTe!99(42)#)@!C&UGf36ulG zwZZo~#X=urW0*tn#&r!Yj~y+?bGCOz4-1*8b-IpWlmls3+wq~R+)(UsX9EKs)_x>6 z>i;-y48|&Ohx4e@f~Up2Ptp3QH}&6ndUy}8R_l;d*5P~Fes&uSI2>5lgzNO{o5$a(XD zecz?wZ8G3jxvo%2&aipt<>Z&b6@d!x%BMN{Y4|o~$*yodl4fO{s-{!j%7@O9VXbGz6;)x!saFg~#7+jQkyvY1%>;R=m_RI~JHkza{XXcWHvpO#~~L4BL7E#`~` z$cc=^v(-v^cTp%)8oxt%4=4a0p`}{*DE{Y7mEyeUlpd<>i<-2SD0=cVNL_PB#~aQj z;c(B#+dQQn!MEJ-P8qye8CSTd)6u1RjryDJ9Ujl^?H0hB&EOBQ^R~$I8TCs)0c3Io zN@b#-zQYNekJtL@){OqSt^GQlBNubtSHOF}j|+pUN!J?vkRvV5hmLlm;+$~WD}P8StGjSmJ`zuKUp&!vMJ2C@pY4QyRxd28 z%?2F_jsoiw$*B3jA@9OgCdcB2!I=XIQnHwpGQkVM3caFP-=}FN$jN%hHZjBMG)bkC z?NFEWp?$L}RC=euCOXh9B{B84l%8wZU1pD@VJbzj4s=ZM8rnu`jR>1!^k_p`wIg&& z+-5?p4LXw_PKGs4R5?%G1T78ipEv2nrnOH{pD?j4bw(8rj9C}Rr*iM z6TG@l@R;WwW*F9tiV(B1VwlW*E$O97KGS8bmxor#u81mosli$~6;&l`>Mt9EoV)MY zED1xU{U?nfs0~8C2gmYMY#!S%;fa&bxEew4r4VLDRi9ra6U7 z$JRFv&lQ81>9=*5DRzG}hL|=m=OMrpPT82yVD^U?Y4eLL;-oj&thzZMv|rKX?l0d4 zhByaFAFpqiQ-pM6eM32n75`@MJeV2MhFaPbWVC^?t{|gt+nDRZND;E5zR9?kL#=(} zZKe4l(sXh=6Gj@CW)}>7v%Woz1EIs}8^Fr+)*5zx9R^D8p$iBoPVu(kX*vc#!C+&P z<5?x**Y&NVXT3nyj*XV)mPpZplgT9wEwew#1bl7GvOCvAOs;PcXT2c%92P&oIx(_z zf^;^psBxgvY-5Yv5h#DuHwD+GwJeW$MVg0#TIhJz8=#vH?UOCI2|+f$`Y^WH+vexc zXScVmWhd0lPD#OEbP3sf$O=cmCY0LPm`P+vE9zTh8u4WupDdi`*zUE*7dER zejx;=F=r*561(>sM=2X@cSDf!gw5|Z22k4WIg8ljHujG?Y!Y|e6=br34Gw`x+l8i;mt1hAU@sSb~%?}jUAWCIf% zf{|cjj{_jmTHh`XG05NQVUX>pWwo<@aCU<&HCFWQ)a_o$PMa2+K4kj5J>poa~uu{RNjjkPhz33M3S(5_tBN~T)E4ry0t1LUxw z4fUo6{bTd1wy@z5n;)Sw<`VIo)44*(E^vk#HvCPylZF@@FJ~Vx%(k%x@r!#;%RZLA znf9roS91X=Y+#f_pfJ&PW$d@H5?jkySr6;4m@n1NEW*?35FKn7q1gooVTg@A4zNLS zrp-#xGy6Hl$sG}s!)VbFGT2~w8UqHuwy~mPqwTTc&H4r~$d!4I9VcOeG+K3l2sZeA zyMW+M8zUMSl_b7Z-vSz|7DIDxmxKeNk8>RbY+#Xn5U|k3B(J0A0-xPN}4yBiCkh5nA!=aOg6r&zH0&(hcKlsF7TsUgXcaXSI5LAA@lIx}9 z?Fk3J6k5U*cyaQNIY!!&Bq0&MCtP)nq>~Q| zgmfWm0))bnQK|(S$7YTa7Nn1i__SbU>kI)Y3^InyC)c>4cn6bZ6oWF!(NK3>R5!Va zlgzGNRcYuP_Urmuu+E3W*^$ge!%|uV#L1&LH7x~)4!@=Wp(FNDYsvaba*v0=3y`~> z=O>XH$I@T9ociHW&6Fcdm*rtSvo+Zp;`u{^)F6wS-?Eg~YPj-ve&7q_Jo#e43xWYk zUQ|^1q4Civ}}lp&>Bvy$j%z1*}1*7X5gXJ)Sa|ch2M}MUcfpHtyN0* z7AK_MSB{eA*qK4wxQM}ilsZcu;n6zKQ_=12`3VWE&Vi@_SMb`8q$b*9hX1nb+h@*C z7sojeIbg{W@)swo!y##8?=lW)&sgVpB;0UdzEgbiBL|-&_ar~>v`GYFRGc9%5?Vbu3!iAQnPsHlT6-VC&;tBC(ZXnlBs_`KTiy@OF z0EGvgQ+r{LZjxN)>w}@HOpYDjVRGtTd~wdZG*5Y%DpQqNer@S%vohg|IM1{RFI)FR zlfzWxQxYBd$*X(FiFkOE$<~wiBWI~ja{W|7W~9(T6|FSQYIg;1tl+Q803HT{PEgfc z|E^dyUNvDR><9nIgQkEecpK06v*I7@r`!hQq9xY7k>E(!19LwlLv-Zc^2=W69IfH+UebY^z&#^O zZbAAD-qYeE5m|J?`y^V(XFe9h?ca6P2_c(d`5v7B)APEN=5>{^;t!+<)*#+ysGa+X z*aO{WBrnjR(tdz{kSFQPX;&(vLQcr`pwY1qJQoV6$v!LEE9OpL9jv43Koh$WB+qNLKU}^6KL7wDU z^0%jF_brT~^d+%4sB~a(>2cUl8dE(Df;Qyg`8r8Dvds`HGD&X$-T+^T;DHmG794E>IO`VRsOi5L|uaa-4|X^E7L_R?CH}xtR`jJu@ z`VKj(G{K&^F@9oM(x7phpyyxBZ3W?vJUN?$Ex#pA1$jN}xw*Vhb*!nhoMwjVLRxa;ZPHRR4a>mMId@blxRU|}Y| zdUAJu{_u3J4^O^LxJup%xj#b3#1SWE7T ztc_S2S0l#k?S)~R$?S_W${8dbN_P-9Fa!Tm!g6r_H8NyyuicZhDY#tI`ioC|w^j4T zzF7X!b@QP`$KL1LK?Dq`Y&Y>)1E2GBSwWh{JSVBMx{B*+^d&b*Uy@`v0He%2N&HY_ z9--v(r$h52Z{QSfWvc>p+1e2XjpbTge>E+AiG9d=M#c4|tXV`?>8oy3nCP(TJo~cNq8*`Dbi5pDr=0I@Ojsz{a(&^^(Coacf3re&QFN}M6b*~|U zz^^4!Tg36kplJL-UVFIpOY;+mNXm1BcqSJLhmaI9JT0vkKOqZRK~Cq_M+KJr&hPOG zP7~Jru$xbdUmT2U^gj}V@nj|Pop8=3hHH3$@R*=TD7q-TeGy@D9D+&xeH0zxjRSb8 zEDkzW1{I3LB};Z6`*>YwAg?_^zOUeO$;uDNT`e4fW9x++^4b=j|4zeIb|x#yyW$(h zzhK?!Fmb;Xox#)E4$B}Kel>>i_l68H3Td7H_@h@OuAenX+#+v>HMW+u>u2(%UR{r& znB+ZiM6m%MpyUjohuSC&D2_r7mKSoaOmrpD#cEqH%pda$1fC4I)e;8Hg8U7FK$0tU z#7fo{kc;FNJTW%n_4zL^=qlt4dLMUFH$LWdK{bLa3n$6sV-oRtI_!pT@IUxVOf5*C zDr62D2?nUVRv_r63S?v@xhNz*SulV8iZO<8f~xMo5m#BJ-w8h$i9)YzvMtht%u*YcKu)uYP+%t$u}^E+iwrn8m9%9cD_%)=jJR zYM$?#$(6?p-%Xb7erccnE?ict#l1f&L;I@N`oJKV{n~`S8N(;4m(1ebaqU7#5%)b; zhG=1R!w?@vu93GjAFL(&7xKIZOk@JGQwR50@Y-{OQnfwexx#qztN=`QKul`R zS3D^r?qNrmO?=IbQ|*{(e3y0b3=Gin-hER)K0G*n!B-ljm&-V^hn%U0{T86TO~!uh z230YF&LgOF#2~-DopU>^5aj#&ov94hjea`u+L^mX*j9Ru82f6--W zz%Sp9`DMnafZZxp$D{3FSFahNILVt_QUw&WdMa?}#N=5q|tz z6)KFkLfW?4s;#K_Q(U3tA@$M%?OyE&a%P=*<%n7HUVy<{V9}e$)@5-eLW0&PXrn@m zC0Zlj%GjGfPF9N-wYq7L2r~pSkIY?TOwcYr3>zVSyjBaFDVdeS{N*Aidw?}dJJw{Z zS#BrqauIWe3l8?lHFE3g4t9vOx|3DxZQLJUjC*KCgUPR(I}ZKEum@Y}!szUGnISwD zq#(i@pQYLfwCG3B8M|TLi`%BAJ}xp!i*zIt)??h^EO2JEVawIu!LxFN`bO1N_?8?d zcV;T@Y&ZlrjUhwB`4Lx}_w0I3`J2Wp-htddj+o_~Nu1X#DwdB5&~qgkwLz`cKY6Su zMDYgFt)v?FVc`LdppP7vC4Q9 z>V`WfH3OP;;|-&@vN%*#K1-I1m-#qY0~r>CAC?mxZ<)vQ1F}$vOl5)Q8lQlv{in>E zigq#9f2=C`C<))LM?D0XGn{1HcRx?~z>H8#YiV?@F_Jbeht8)Jt4@J2WwO+VMXeGT*GbnE(Az8xtX5R zB)YwbB_-361y(xs-WVdQ5u(S?9sOhD7!C|q^-D1adZz95-0B(VX`cEj+1JOcS{)t8 zoprQ;odP`ruP*$4bY$TExBG(UV=7I)c|>5~2#}#-CT2uO$!`e>xk71wNQWgxvYu>e z#Z-NQTQ6g7XAm`W0Wru#Np&8>Y8unZ=>eq4qRha_IU3=bBf>2ycoH#MOQJ}iiBp>c z!2*7*X~QOSfvoA0T{UqFIT`?g&~JDVuO{2>J%=jBx5?#q9*4hvm%Blhc;xBW&3d9& zgZlMTNj~C_6Ey)rDNG>&GCb8I_KEWG5C9QtXO@z8%!O4;l<&?e&CHywysey)&SzqF zbWyya&}PF;NN5IAzWSTmFK(_8nWbD6qLj5YPBkujup~Q+mrFwtgxcf@15J>5rzB$x zMY9_E8e3@AvQH;OL|pVQ{wYF5Vs_733ql0><<_*K^xotwtc7u-r|VSDY)k4Mk<3;3 z3x&t0pR!K-@%I6U)7=&#m}@nw3ik>c$blDOvM>^I3eS#SbWSLn=AV87Yene429sad z&m=LeFoEz!QQt633Gp?dnWzQ-0k0~~ z21Pb6s0<^ggC2Y4c%?5X9lLqUNr;E8`dFn=2wFOsNjVg=S7nkM6RR{*|&mi#%Ms* zUf%LF6f{3TehG&;Lc*~`&l?3$B9N-~Q0Dg6U0r(3d*V$JAfSYU$b6J9Onc|VF~7{X z)1}X~N&Nn1IP}REri5qD)Ray6fm}kdZ(4IptKQ5lM9x(x-m=8nR~S!*oI_P-YCD2V zV04$D0izxR4IH9`C|Q&R;YBP7BpcnJM>pstkX*7=jBW$Nn5UQne+$Arv8MS`@3ds* zvn%)O1TtylTzG!&V;Z4NKhWpr^+l3bzbb+J^38Ww1R+~|yrvr&Ex1L()bQf-(E0c9 z4SknaecNVzHz9wrZas%~GWRA`KPm5n7)Eo+7f9=cD-4(nqlK~vdTQwl5FxXeaaGm6 zm^j_fm!i;Ocp9FKs5vC&`=gB-rWeyu@Fz#b12q@O(Ug_`o%%+Y7zJ0hA|h!^+kpeN zE?zSrLWn5iy80*mbASvbzZkS6Nt{_TT7Y}ZV&*rafh$r8y_dtaeX%9KC6RC1PT#t6 zq-yljG=#o4ym~cz7;CqB=`?%EO-YEepqi+=S`R*2G}qwfPFG&hLsJNNamD;Y^`dqai0&y(F9ne~hg&S_Km zFi3*=8xF#$O}L*ssy|>W{tKRur5`uBOfKzuBn}4&r??BASm$_tky8z1r=b2MCmP9I z&FSj1+@$}E>)ZP{mvDW=Y=)4p$OO^{+Q2p@ZddjSD~Z8mdsueS8ICD}t#Vhu{gqC> z5$^7(UIo8loKGVq{e;E2A%8wrx1(SJ;h5vK3;kdtsw_s7g zqUq>a${(1zG0DPu1)L1E#Yi3zJL1#E#{_6C-!`;eZsbTo0$;Ef`8U% z{}fjeJ-I&drITzD9K%xL*q=3Ai5B6j&z25r(M(ScZ~8y%y$4tnSJXc|cXk)pnOVwq z`oaQBZwo9)RZs*)K?SiQAR{~o{Zx#%$zy5o%1{A{tk$E61Qg$V1^IGi_o7y3H+u@n6?b1 zf8PI9l-Xr4XjW7gU%M530PCMlKxioi6f*z>R8T88doX9HAW_&$4}d#`Vxpa5JmCl~ z9B^w#eRDM-VuA(l=KsK#(0zK}U-+M^{0aVEIQ}+~?u5QVg&EP=T!m9wc!iI+$8zUf zZSp`_r5-QBKcBA;=J_D7xO$4>BBCK2q(Sk0;olkK=ctW|f6dl*B>FmCyHzv>{0EW| z3I>kcsImd8kthlYGnueB_(uCcbCcpDG1z0orx#b?a;*Cm`J*q;4&>Y)&jUTV;y%W8 zesm8y6LkfCJ0+7L&in042A@kgmYiUsuD)v&tqInn3L4K$hojrsX@#pcc522$4pB_ioqFpDjaj9DE z)=8~qv(&7(9~|+6;oX-G?R*?REH;hD59cjp({UXeY28vq?)(Ne%>dvG7fXa}PKbgPCY*F!I)yac0x z=rtPdqr%F*+uH^}HAK+lz3B5s|9*1maadTxt&#@M%Q*FerF1Vra;`Nt`d&Tl+wkBr66~7uDt0ju(9ieN;y@ zO4JC;7)XdzdOs2T{m3#%MBpGGy}fHR?qxnGnvd3@E=Vi${}BS0yZNq1#~qWcrH6?M zI*9XR1K!%?iaR}U#nYhL(-7?C>WW6u3|)Y}8z8fn0Lq}z^(sv4t@HGJg!OaX#6R{!_FX z^%(MM#GbxrnfFLeRw2&wjkqN%+7ce@v=WMS{fh5IIGg#pHJr$oo=#78&borK#>o;n zRDTn78rbLEdPEjoz2bgJ?7g;*flD@4@q5;QruXp4?u$OOgY4MWf$L!VA!v z3MH6#;AaT~1BS1V-(w=yJgnAWEBo3KuN7iOXvlU&+z@BKMMiuREBU3{A(Rj(N=2pCG(;xuZp7a>bBOJVcfGh0fAevG+MEB&EXKJq z+ckb%#`|dgI=>;tgRi9wDUrqLQWnDA;CrOot<~lDFZtz?XWMtr7ho9KriCZr zWwjqlX14 z8th!&!wxEE?g}=&-GL&Bb2ELBoL*?jjylNVM z0`fk#%*WS7=(5=S8B{(xBQ9hi!appC^Gw#U$tu<}MVILe!-;=WzL=TM|H3EZA0YK* z848}Iib~i@ul{FD5?H;rzJd5c{YT(WP5EEO7c{N7R?it<=>E)hdcGh7-&PC3x0*ZD z?h^NfIJ{DrIXaQm6s#)nqy;(+5^8`{K$Ap>DZr}rIM4tfl7y^auz*2sL8Hn4*x(0y z|HJy8J9pN+)_dQ;o`GLhG4*sZdm_yX{4*m9NOgL3=p7gk5RFfF zj=^qxgsN^kBe6|plZVnDpvk()yv4&P!?s;Mj=;5>5t&NC-V_U6- z>c)XDHWK0uuv?j=a)5P4BZ*QY24@S<1sFDfhr&t&t{9A+7?iFLjnjJ6pTNI;P0tl= zsJqK=TFCpMjgt7#91WV!y)5pn&z^b_7x9^^6^1YB9XZJ&)G=$R$Q!K>?YJ;bGu1Jg zJGqzGJGA*nuoY_mOH84e7O6&P0r|8}yfKc`42KMWLYxUE+EF_FwN7jC-&l#y;oMH$ z&r0Z0^t$*Np@ngpIc9w1o+bjfGvDEF>QPUC(5~a8PN_%?BOBCKx4ZT=*eB5wsxLuL zCx)0KYNo-)yckmq?ZLe|(x-086?&RzMIGk1FRTkfC6ZF-xn0Jwvbrteo^0HLH~2ry zcjbo9>fKn$6qMGpN)(I^Zt2KrOl5l-^md_kjrjQU#u1*XGk{}SjU$K(SWOwe3}$@~ z)Daz^lJmx!Rx*+C@IL}h{20BHvh&|qWI7@dd}0n;?8AlT@}P#Q3RiK`%3;9v&% z3N9iTNy3~CIa*-R5ipv#x(*0AO!bTo%FN6SjMiQApv7_A?$7w!_#24hj7Qh2VXqb5 z#e49V@rCYrWh`1PN=H&@T8<`oN}#HST{|MzGj?u4S#(@G(LS9)ubav+H7fjL)F#yk z{@b{~+%<*Qb2TCO1eX8FYc;ez{R_Vhcp>7`(qLQlPTsIv`4w^sT*ghyR zUP;jp>uT51qr_#6xFdML4KWDXKyo!C;GQ?@F%W_P^q+(vfXfctHXAff&3btqQ@=X| zB6U~rDclABc>yN4q4jb6d$aENpxNTP#r6eicFKIQvAtKQxJ=FJPE~BbJPxhkST+bB z{2Rigf3KDMo~h4cC5fng#vJ-Snpuo|i4 z=xQT04#cR8wu;BdUW*1vCT=nyAW_EKoYp}#t~?B~Y`72bi~J|}3^>HgXnH)Isl_YJ zfAX*6jXv5MQ+*(WSs+?C@t4(FB*i{yIY#dmjDsD9cAyaV#hu=zVJ9V7Cj zBf~juhYslM@4XT@MSGv?L{9VE2MOXz@f;!dTmubHdH}355R$Nw5PyUCP0(!!04d}Z zLNJFw<|HEloG0IyXrK|8JFz68d}Lpv>ri~=D*qkb4OwUjhd<|ku3g6ioz}D~Yq!tJ^K9jLwt05Vt4CC&$p{=}AkKOoHWMq)GgO9kQt4@fw*i1o zLRIyML75QOS$UqVJkO1VbFDnji#7x<`t+2Q=Q*%@BHk5+`GoywVeSAc&-0Vj)~!6x z4aK*`Pa##g)0B)4Y+WnQ^Yf+HTX~)}ngO_$)1(-uYWtUlj_|eeJd-@i|1a=7Lp==Y zP0=raIXVjaB25i@6(B4qu_0SFRj}a*c!vP&6sWTR(-5{CHNZ;}mB#1?OwTQCPfPzz zxL=J3d6(@m;ElaS1JG{&Ve)X9V#l6@qBl1Mhp)u>{{8LJkq@DmWn71TLPNa~lP$2| zJ}))P<|pZ9!S}ZEKId3@pZ~3_VC8)#yNaWp9HDK}xmbCht-Q~OgoR1)05&N{N5K|v zh)39v{jY@j!wWX_L(sUTmRhHLvGP9a7kT&iCu}r?$aa3qeZOEWUV~0A(&(+c&j3{g zL24O9QmwqtkellL%!+s|c$S_ADYf!GlR$l&fT)%CSp$~+qoHW!eMVN^=SQ6ZS$Us5 zRj4`Lv6c6^v3Q1+_qj3tF&#e`Yv+_RwTPMoO`;2!C{!aZ_@L={kmaS6Go0sF2Aooi0v+_P4 z>LcT_EJK{#^`m45r>(Q{K3jR8VZWM{_u0z(tb*Vv@P-8e^p3hOI(8~L4<$bpDK3jR8t-Q}x-sc#odZW?I!zuHx?nj1H@w3?!+U9*m zk9nU7Z!3_e18d$>sh^+VeMY+l`sTi^lRm$Is1}UR;#{C_{tbWpRJ!M1ermz>4BOv; zpU}$jY~^?su>01ie&@(WR%b9rt-Q`w zUS}(>bE|bbth~<8EFNj)bp~p5V+aFt4z-bi8N8nT+LRl=Ko)EHS+=Zcgstt4o0Utslx@OPZfS5 zC}X9MR*+iHuyOH8niHI!RaNeWjkJ%RV#)?iC` z!%cdq%B%Jwb*5o6C0qcqErVXsl5~at94(4*Y=vHAwmiIc-=?~wTTp+=3o7wI?M)<669ROFo^mpgYOqkAj+6&7L}^Hw3Mfvce}Wc< z--Y&ghNZD3EZ)W*?zq zOG1{9_w=5SHAKx%X-+~Yf|_i-sGXokIpqeLggB9{5@}3O14W~R!kT0UDiD-GHQ`6( zxIizAQrUDBSX#hqUn zWovUKRH~4wieIo{pbf`jg=G>w4%#pPYQPG0prLdcyd4V7K~)bZB?qkak86=aJ+gnZ z{hKiMf&mQN3m6Kcv21MLLuW!t`le>*Pn@^RyLfYmdTy9!tcG=tQKwePgB?8?*YOw7 zrjE%<*HC&K%f2{*J?wR`y1T}^FZ)|8+h29U5AR{m!h^oB#iLRComEowAtw!$O8c%t zB8rljr|{=2_YtO?p~nXU=8{O(B^B)3HR(uoyk|3zXw(jLQ)P}}JFsjARNygb;dg98 znQpte{Q>-ex~p2P^X5)V&Z);0Kf%?EFqGR~!hYTU0P??-UiNBO`~JP~(GYcF{7f&_ ziw)kt%b>=u*>u@nBavQ9lOm~G=}M^siMg5A@vl%9(L<;O>jkv~1ld(kzDg*k0%dn~ zq$+}~L5@tYu>#8N+8Run7>%E;4L`}n{|nc>d-u9#4~L>Q194Xz*kOQuib&+tKC6?B z%dwN98KTtwon3;_Lx0z3nZK)lw9I9;*-S5z$)(;P%WXNAtdB(7==MRJYcTD| z6uA$(D*eY+V<@*sBbXu;a?N)y=rNV=D9PAaV`KYGjFgjVN?x#qI&%_!1HNfVLqAjY zlp1DYLqn~12&fZHDkXY@C<^LB7zsp93aCYz47M#>7kjxBCC8-WucdPNWT`$R#))b#dr7WV{^h$xRI@bP92e{v>*McJ^^w!;(CX zo!rB2gq=ESA|nT#O~ZaLz9p0sCpDkX#C#TCYWWcT26Kup6((S!paWps(!;z4 zq1Jb+;p7ttJJONGU7dXm3)_5l{rEMro@A6#+WziCT8$ z+|Pm|xnZGQR}Ngnp~Q7ukDj6Nn)tBM@toX!zZ02=U3#CPU#3&TBrYN7C!Cr-kkhlA zVi%jP*Z1hbh4r|v3FD@JBksHtmW0b8Qf1?k0DWd7zsDkn_LIR36QX-OmE%@sYQMeVq}EF~XVZJ4?V@Ba7wD(INuNp9Eato7#g-X#4y=7H zFq=cI5|Kur=>#qxSeZ#RL{dNsinhaC)P$z9VZm9c`gD`MA*)}{PxV)rvHPZ6F_p`wjyjm!cd4p#LB>2h8+F(*LP|F-E-Pso zzFlh>X89CdhV~)77wjpaRthnrphaLb1A$!3lM{1EH`cj4U#;#FtiFk_xuUn_DP7ZS zT#lX+%@n2eONR;h@^cY2i_LQ^Ig}3OW9`4agOpqZ%Y<;)nLJ^AgF2C{RXcN(k-kVo>Yg7~S)vtBf;Ki!@?R%|X=IngBAfI&}_LuAyU)b$K*LDnobPqwV2 ziqH+1$sVm$uxP4{gdx%|8WvE&&=iV2LL-4F8WWgvl9uxcNpe1>3~H=TiCTreIG+Q1Mg;qNRowG?5}U#G)d-iOhyj zz{Ui`B2X@kT!nv$$fub6^iQr_Qci2<_CMrmMmM<^!#i_FmR~9Km1$MQ$AYxlaNk~^ zg#XOV(rUZmPgV@)DrVvS;&fsiUJjP3q)M<3rO@U!W~hrv%|L$%widCU6R1=&Bm8%k zlxB-DF%|>=6HUrNxnAtVx)yv)=Cml}8iM}PNTN<=Ow?TbsQ5x0nm4(ONv2f3PYzyC2;Gx`Op2FoMeKW{H{aVh)k zRCgCmtj@dd_z&4VM>~u)!0gd~xy)d2!B6FCHouP-T7E!3fDIo>#gTEYBZaxaZuS|JHD0DoCQfW52$tHqsOskW1R;VX5KI)V1> zh~IG)-?Lbtzq>&#@em@f8hSTb+%;;W3Zx`XJD8^$SYn=BY$dp(7#8Ql^oShaev;j827o6EfNUF=wHd+KXDFelZ`>L!y7EwP(q=N?!s(?EX^ znTO%CR4&*Wk8C06*D$4noeajIP-z(a4)E5=l1Cg8knlO^6Y-sKnPn9E7HoPg_#80n5o6v~Ps2(V zV~Tomjhn8ly2mg_ZhCIc_@Kp`zg1Ru8~1%b)LS`Zkt_dC(-(=%$8jwsLHA*N`-8nq zR#ou9phO!fY7G0oZ1rGtKq*vVnS~;H&1O=t!1#CBoZt`*7rvaE%W1ffl1=#M?jh_X zZ;gm`(%U$)t^>}I!5c|t&HGxJ_QaSoh2Glr2Ydc8oK+-;57cT0{(vi>$G~{~(ogCv zl^3qEfgS^E{)1of5z9t&8|MEi}_-l1npEysUG)i z4b+W=WtEunv|a>Dt0_tp1HC*3iflj8Zu|u`^RnJfy;r?yb~zZ%>h|jCj!qF0b^C`c zTCr9a(`D>SE5CV3jZWj^Ec>7fav1y443%b#4GwJ9rhGJBYgvwde`M7Hv=8IV2nGr1 z*I+q=DG6)7H?bz4u-;B^YIVPCHnNyyHQrO=`fthh1dFmc5ca!ESK}3zbZwFLC?XwC#>fXPVU1!%1Q!qseki&2kB;r=;tSUrYO2gbi? zp$4k)m&z?EqC+ip#i$1z&djWG)w*q&;ofDpa+s1^a5yFK^VRBilT{0L>+s~}YJ)na zU*XY~8-U3v1Vrv>sA>xuTmGM?fU3RoCjHBoLbUUIFY#%zn5Bu{t&R#2qK&MSx9P2$ws#Yr0#8| ztUsOwqmFDU9P}-kFXI7NYU5v;vTxY^FZyY2avC zQvpQgyZk~buMrm|f5o&zNSy+-VK?{IjVn zG#|&;T8<$zV4hw=-)Vt(KB0~HW`HMhYxYv}6L{xbRe@CWpisNW_$}dXg<~AYWWrX zJY_>f_-P1ci}&-(S^${Gntl7Nr+}Bu8eU|cYssa$&~gD86k7e+sAWqT)NO&5%~c_P zI%N4rQ&oug1YBpi0(u~LC5f}OidMFe1dCgMmFt>o<4LIUqoyww?A&FRMDT0x3s@yt zKb{AvY#|0ewhf>XH52K6>ma42sc2$z6kko1JjN&66UqKFI@v-_tbTrQGNqZ=&j(Ca zHvIyEA%*bwscC@LllU*eBnig)KR_f~e#ZZTx0$A&AB=p0w;9%}y~qeLIxmV?)z3yD zTMC}mCJ0FfG}SpRX$yd4nyVV-^Y?ItvZkPBx#4N#;_%_O<@ zbTIJ=&Svuf!G_1t9#PnH0C5 z3J*3XZ6=rr;9OHEXIk@EhE~zR7Bb+kTLA}mHkE_cJOvm$(^LyYhA8O2%fMf2nCG4w z5o{rm`SVr*!OW&IA@eigz-~@tAdTn@{#&I+K{J>|YIXmK z(0st(5M=hGz5_ec8}j#v??%v=LEy9}r3qPZ4da3xh07^%K0!{C;%R znV(xZ;wKcoiQ{C--|?+ne75G`_gn-Thh80f1OJfajL!R1a@-93E;^rJp?otc(S09h zd=w|)NG=?{$47PrC*q44Tq4(@26>_dm9jaTMy6D9a}q0jzs^_@pUl~lK3DH)E<-ff zpiu%AWt5OKAW*7}#3&ZTZqTYg?G$6A6sBlLh^LW6FA_9|Oif?N7=q{sq-x?mYQ%Np zSd9j6fDPCza=nB)ZeZCl_|DP9<6p*Jd_z6%2;F0;ci&WX>c$ZEP>Bjr!LuWBS&bUq z3}FYqfdp{R1ZcvpLA zueuLwLPc8d((~6xcfw!cIoS{mXhuMS>fX75%>j0kotNC&K4o?yU+s@rQ5wV%xN^o6*S36VZxj7&yk50+YEgs?`dA63kC=D5yihS$7xe{&s1 z`5Zy2&hO!uwY?xD?<>1v^SyRf1>LuemZ2AM-1v>$-Olf!jz9m;xSQ)X)OSN2(E|a& zgINy{P60B5&MpSdM_W~-gi^@CWThk!ZS!vENf6#AxuLj9mLf~Zzq06^sVV3yewJ}F z47j{QW4&egYaHNr_3b_QSO_{vr|R%|zp1jf&;uEl;>UHB1u({`?Z1Li%Q?%E5sL;R zEhjVI`YB5`N>(!k9YD^02aPTUbQbz}MtwB30OH$9A-5X}C^v?!pi4s>EeJs+9Fl=R zBU=zW5?mOV41M&3kS)qL5}Be5G5EFM)44twqqo{2`v`4SI+uYMZnqmY`EWZ=PDRnq z0rJSpo#)~PSNIlkSMj&=W@O-Ru*5f9v=&d} zzo|$7j3$2=y<@_TRk_gb5y-lMilPgtaQKIiR;VBehu)+BG)&Vogs3F| zb&-k4TLhCyv;og&Ip_`CPE9{Y)YXUM_)>1$$9vGsi?~#a>z97%hz22x&&Qu@kqt)| za%02rpdow0KI36zaOZIV*O~r;dpdV>{eb$b78$w=mXP7m`T@-hkPJy!RU&9}Nuq#7rf|bJw;)KwdZB;Rl_Q(I?t&Y>I3W>!(;dyZ!a+g?S(MFh`NQZ;CztM)|l6iqV22fKXltGf4V(?=7>Lnirx!uBZ>?fm;9jI@v zy%+yduEQ8yyJ?Yw+W<5SLZZ_Gfxnryv5(s}Cn#glfH`jVcs>5d-ac4}3Mz*e_@3XAI+c4xeI-XwS?*CEq);qF(>`n2vz!oFZu&?u6oaj=yGQLZxjn_FUi^0h{))5gh{?t{$@Oxt4?F5DY5?5 zT`$~L)qOfSv&b@~Z3H-%j_HjkY>ED_yj@%7Nl_zeF}?3?sPb6RLNy9K?By7XI` zbe(0tMh;+vz&u}9Yp$`p4w$nav?Q^y)hZ{TiPab(j0wVuga%B8Qz7FF@=ArQ z(rDXAD2Z!>=M?b^e|ky%!M-5&^II{8<{drHwnx6Gw6Oi?^ z(Cc~z-Qh-kjZ5*Xt6`v`H~9XX-n@mK%CR{^Q8eO_Dn}1?q;tKQDxiZP_Zs|Vf>V%D zMkZ28q$|K&a7MHuSZdLbP6`otl4b%a9iTP|q*6kk^3&TWSaa|YT$hI2hwlj0;IF#h zowV@FVALB0a3Ngv>#O&Mr}p-s?c5e@n?D#*;P1CvacL2dOuUa$y7 zB_p^H1qq*!gbFF#D#v)3Gl(W(I?;sDhh}GREH1tm@n7hh(H$Qgg}=bQ?NtKk6IXmUl~rv%Oy}U2 zHm}mDShgSq-3T1E8!y?tf1j=neW_CMdv7qF1*)}jRD!0xQ&W&UY_w{@RMr;sHn;u` z>IQyXu&_?5jj(1LAyfK4a5wYk9ft=DNMki~)vTJI(e0nF4x#zl4Jy{K zP6e@F93B!f=bD;@HWt@?Qa^~=0N5$n?-M~~fX@Ci{@bci)%E#tU{uqVZACv`SLEWO&k8$Em(nCn*s<_qqeqda+tnb&_=kgX8S7*Z|B=c|r!Ljek+U9Pi)h;&i-}PAJ^4s-i(k}ea=7*!Y zKkBzoj^LY+{ko)eVJDdyzMI$>4Qndw3)FkN!+ZmCqQ(fL1x%2Covvsf7%@?x2)PNv z9LJ?3?}(Nr=#Q3ra{cx9Jd)bIr0L)PFMhd(MX{gH(d^ZflHL)E1Gxm z=(-N5jtkZ3IgPb+l&d;Dh;OIccxGmm^#4#$bt(R{j`@(Y7o#B_!b$l2>P;0yQm-{457U5NmMJp&!&LFO^ES_-Gdo*@NJf zee3dLg4jjq2#UVkrF-Z6Jp@2x()x)hDGLw0D@SGwXA6t%NBP)?P-|_`K#qFkER%7|V-G+-{m6V4B|$ELM+0A1 z+jx0v`diNS4vWyrqBd~LWm;K8#cq5lH-ek(s};Z8mID9Qa@zODe=^uxyXp1ZzbZIo z`|v`ow(u9ct_{^u^k1v$_fdX;sfSZ0NQwfmjM%ag()oy^NO_4RBpKH?#@ELb4ZRSo zv`B&k0_ALsD>5OnEeB>1yt+r~@X=fG8!pHd4I0*kRpD**FY@Q`ef;IQ>FDp<5*yTk zKk`D*79G~9km~&tk#hdVXtj%5v^z$4Sgc*p!u(Ol1^KR>l8ev9WDZ}zd^|NbC1o0O zpBb0Prr;R-DSt<*Nkw;2r~?{z?H{nw{-~>gIQ)--#Ua>eLS`;_aeDX=H9-y<3f_Qm zi6|#wml^S7h;Ir^IySgo%lZ0#>YR1cSB3+3PhE>RU;Nv>iCq#0wB&3?KRR=nZ%#U4 znRw%NF%ISLbH4Ny^UBPpjsq@fgI4pg&}o*HQ1F>wtj^ znG3CF>bBMwe&IBlfjzmXAsnaG&xkMB(L*QlZL1et*QSS^(Z+A@$+4Y(AEkV!otRk>^7e*3{ zrB9e|bO9kb6@GZ5Zr&^BD^&RBf++_NUqsnxyiYh|u=gCWAc|$!@b{wvyP>;>hN=R~ z%$|5WE>P+E)4#Jg3wKD48pz~lqyFfP*WKRs{04l#ge>=I$&GW~YH0gg}R|HnAK z?=v!cThXfd0IQjt#v$>0?`kHqnvmpE_|FmzY|GxACMjq`xwMGY40*SE_c87+9=jKM zbU&gVspeL`n-=opX3fW`>Xk>l@!2-@{2-uqtz`}Rry-*G1ZOkBrD3G`fJ!0R9dvA< z8u!QZ_Y#qo!CUb)-p3O;(eu%u)7$yI{JQ(byk+C)g|yEXn^A)I%3s`33gfrVpF{u1 zGm1a3Q0FcvjeI`tg3B4Cq$kq(2~6oL@4UUG)67W2iH|&xK0#XS0pp$$Jrvb$r4~Uf z!V^Z5fOYA}wnT|oVyhB^V+J4+(i7s9WTe-DApx6$00lFc4Bn9a4s0zp{KGWl%;0tS zC;o5*@}ZS<=xIi3e%E|`^frH*p?_McVDNkS!%)v*MRI9)w_EXXSuW_OZh6eLH{RUG zFfn{RZ-b9B;(8R}p0pJ?zUMwQYgFFR@F{6b{2;{?5pb=9)viFHf*nARi3SDQf^^Uo zDMa~_o=IkRU&<5%np0_D#}(N-s1g#v_1;*Ay14Us`~_GV(0=p_`j%0`1f}Pz@cp1M z%H%AM0JL2+ZM28~jL4kq8CB!6Js6Kp=m%xMM@#V=-@7XCBEF9IWY9XAqx-wNh!Yv6 zOAVU5*t^3!{x>2A&m5D__8CWpKCJT|wOgoPY5NA8Lggrdt=UFclfl$p?R`7aa2}_!M z*dqocCDTsIv_mG>ib*G#=>G02zHhey-%z9}6z(3(KFMLluzcjiDhu#G&=!*b#hOEl zUQC*E8)mHAcurEsLtLt?b_aP;!O_O#1eY>4U75#RHXM@E#pN?oPCOBd?o zOBIzD@ZAp;0nmpg;m&RZenN{c8ry*vP5R5jq}qL!QrbnB8{o@~WZMGlm+}V4UI`H_ zp-J>Kq6}b@lP*qxCg|*Pd`Oxk?K8KtYv0aJNNv87iN6M{g+-Mq@-=uezP;gW>9s^u zAP!8P>>4BOM7v1s7I?!}oFJEx%4Jt@-`FM6xwwG;3IB=qpbf}9p&-aEWen~39@ZP6+`4Ve$YmqDdH#3^`jul0fhY!I97*6=eWuM-W#5 zQyRpsR0cdq8X77++uv=yN-ho6eif=n;nJlmhb*XHMZ1gon7`VAYq5FMZaq?qy+g<9 z&?UJX-F1~pJNKMuO7aP_T?akUrzpzLRT|) zt82HaFVB|2hjm z+?VkJW+m>=Z^dWOX1Wg=sWvF`k^Hr+Wk{LaYa`?_Os}18X$N~%bOOpsA=vB;n=S=) zQZfYdg9;WqFFjacKEQM36ypg;Xm;3p6otz_hzYLv2|Oh~d=I@#5598eecp_Ng0R^H zGHyrh+J~pJg$@b9MLP8RSY;<=R2I|E9bdS)%Phrx^k^->4%-Ytzx|9vneeZK!4*zH z5g%44V`0vcz#6chb`Y{V!B~+4=>=GSf`Wt@i!1;>zR?1`1MD1&!3$lBUOzGw7N){; zFbl6im4A1~$M7KBA1@D{?L~`_8R_t%Y`HXy-*L{^8Tzt7|q_iDgi6wXysztXiOQo(o zzFa@Y_4}#Px3gpVcJ7>9o_-$23jHt?*7)0igV_omo`x3O4cJHzA3#R5t(XjDwL-1Z z!-@slf60nZbV;}w@Jq~@Z<$@|Ix+1u{US$R#AESdG?MN$_}eN5e}r%0JGcy=U+hZ% zMl*>eIVm*F;PnF-#jN^cI^R@JqpxB0ppD%Xop^`0@S(fWv=dr?5p7p8#!rq-Rkn+Y zjbP%1@$6T-4A%PZ!PjfR_#??uk6>X^B87@9r43>wlUyT7tmV z&{a(RF{GncqLRaE+=ZWjU%_9lG@=3YzN_rbvYTbSl0Bmt*2yQ_XA|p*zF)I7;1*uP zm>0xh6RuOzQ|QeoPAN5|-rEC@V_8GLT%W2E#)C;$o@I<|Hbdsb2QS0Ca|4Eg4*nsS z_ArBkbReUj2>yPw+JpL{qwr!*6N(q|I*}4$kGj7({Ym^0U(4^NhQ#6cziiZMy8M!|rq{f=gKVc5Q}AdlnBSGs&?#|K~5u7;?dKg8C6ds#%# zE@(k8ieW@R3rZ2RAI!#H;99_3+?;z^pj9B;%YB$vgSvQmM&UVlBtA$VMX%7VYJBq? zp2HskOX()dsE{8WuZYKukjTk18)FCPqj5dn{)9h15j)Pg}nyn>o+#GJnXR z9mok4&};$|KMR?bbn+dTFfL@He@VJO(~t4&1S)zMQM(p(MZCZ-0Rjv87(ku(Xt*?* zNw9u^%qAGcM(oHY57%sF)mXM0JEKHeg`~eCg0mmM-T0pP!?^3{ExLXguAhW>wm5&i z*S!uM-g@V9(RSE~&ER{u3Olj1BRWqH(Wt>qLE$g@Zv93W2gceB)JU*(8>~q(9;3;& zQ!>fviBYMOHi!d^M8sf=7U&g#w15Z=L=$6`+nMtLlX`;5>#_}OR|ona!+^&Sj@9^* zYI{3+oJfp1<}C|c+6Cpf6)#|%?n)t2<)6o-k_*42#NvE93am+h}6M=qM0PU9!aT0={LyuR(%mD7>P1AMT;#$^!A^oVO2E9N?e_ELIQ2mXvJF`A1a&?$PyZ_4^E{HQ5P z7kZFL)HgY1oqe`()YrWjr;wfy{tCRUVy-6Po72YM-svrO#XZd@@XM@ClqZw033Zt4 z*)wp(@~ECY*-)3LYLHR# zeS8c|&TafQJlGXU(0Ww!et1mON4wx#B@g?}IomHrS1+=%Bv0?`EY_u7P+zd%ygeY_$eZLb(c;5hWRTpX}I zu1Nm!rf%DMXUWG%r7=@O(CFkmwOpQYNh*&F7%b1o@yYkt50Wd^XIx!)kY05@6m1&k#&DNP4j)NI%5SVFd=ml$bSh9fpx4FQ zP8s5vw`h^nPHLA67HX7>U6hm5+31p>TKp3Zu&Z#;*Y2SlXw!4!`#_NYbZ+G)9rKZwP{3a6IJaL);>yYD-K+-R)m+@JLBrBs z!%@B|rJ6n7X?@=fWxX?5RUu4?3-MS8gD#}m&B^ob;&l8go}Qe3L`?gm1+X0#+eM5tPOgt==a#2bDh$$jQUfD(KPNAJk3S~&g-*E>m$)D?bjoVdDL;fx zDSl{#@wW@+2&FJfkZE5777^hVA!I%T=7&@~lSIJtVW;|IkRN~?p(X$Foc#UNMDK0eH#_Y{MYEtF#vqjC8zynZMhDcWBD z34d?Y`z|O{oFC%ujPA $Kke%>|9@aPpG&F;Z!|$@Or~h2+%u%&5+BU3`3eLdGVA zg0Hb!UOz-C?g6HHA9@h#>{{L#<~|P?YrcS$HDEjtL24Ld&tkl2Wo+@MC*D!Y=X;h( z=R`48DZ0pk04RB81SBObpQE0gG`Zy*yUMldf!th-Z`x3Ew`W48ZgF8l375*yUSj`DftJ zU4EU6q1VBdP7w0OiO*pK3yUUwj(L%2d~uDE%W_7K_B zzmL)A&vmG;xY&@UK%{HO(SD-o2U2QGc(3cc4WILr>;!< zAfIuJGIUocy1$99DXKrmw?2k$HiI9A6Woyp`T(u@GAcgq>o-Am(L>MLZ4`+){L7OJwraATP;jD9W8DeMmZDnOZly_W=-Eq7Z ze>X+u{-SxHomgp1>VXuf=;b+f(s-v(_0(m6meCJ-)UL7YgS=h;2D=ALZQ?8GX#=TI zO(4i-;|7KethfZmhZ&p@nBdP9jJx|*#m;<&u}}ca%>LsrEvN6-U&Y+03!bQlI5U^) zDQ7CjiG$yz`h4vs$cer#;G-~%z&?YOj<}}^!7ETBN&|iY@t*|O1Z+njxrCfI z2%8wOfl> zbV_gOs62lr4S(^$ob#CqfA9i(T;#_o&>4C*I&EMeOoOUVq=iczqTSyBA7DL<{~GWC zNY1tfHYk&phxI}5T!`CDJQt9V0DF3aV0jBP+9ZnaXNU$A2QOkq56_PpP}D6%(-+@+ z8yk=X2IzY%Hse6t0l(cXA(e6WL4_hodu7LN_IhQO-?3|jh?f@k4;wUjVp?eL0>#Xl z@+#>*ei?64K%n;l+LIUNme?8DFZ6R?qKJ$AL^L}%L75v?M)XS8*@NB7p!$~h{*%ld z5P%{H+YL}(YnUGI4m>l_%-Pj1$P4Y{`iOO(b(7zBngy{`RyhFIgX1*^M$shnU|qtj z8#oW3y`_n1`^2;YO+@u}@~q_v@qN`w&9q_agn3SRPSUCsM(;i2oHj%{j+pOUrx@8} zJSLLy_iP*}UY~@WR&QY^$`bX5C$*7U~)5@C+ zKIbDYN=OnRV7rw!*~*)2t zY-2P@8c^xxti4v=WGinn*+*z<$n6J%Q({B5I07rJ55zM7qy%^wju7A=+j9W*07wbS z?il?HO~~`XdM6#p%A0KEO}6qTTX~bMyvggVyvbJHV1Vo2CWBrIk1Nsm0l>yvb0A&B~kHP79>bjiFhcY|SP~J(cA`=X3HlX3>2A?q61~|>ge?SNhRTIf?87Pw> zMk59uaky-t{TX{no>={A58AcA)}8h0pR92{>}~KinY`3$qwEW@g9AQ0UAfZ(E$!7A z(Gu}bDGm|p)Xzm~4gal!WcpnsyJ)+5L`g&({m|Lvgj^-=aslN!=PRX%rde)#>Wgb# zX^BVyHNytyEZ!aE}lC zmWhfK?o6|6f%8tnv9oaOYPkqkdI;ya!g-!>o-dsDh4VP!EphOc?r=`t5(jS~=kD;X zICu*=`UqFjK;}5AvvBMRGRMJ_$(^~vc|IK52~TSWPn!Yfjv!$>cp5o(2hFsDr;(!% zJfR(x29mdfr;%e<%NaQK5YBUj^L#ia2pUTeWJ`dj4TUR2wghldLOvH6OK-B z>?@r270&w$=lzBA;nZ-@OgTtMj(PCAc_9}OHE&JiW2wl$uUT4$s@9Qh+^a$VfyTtbPVx`6{MvXf zZgP)=vXgXcyl&Ivx-B&iYKoC}Nhv!jg+9=DE~OIaKO4^(YRltyu~e+BOXGEp3Kf0a zcrK$>i0T^8?Wz36dl>Rvjo-_XZxJsN?%|qTw-?T@3fCRs++N)9?Fu-Ti+2mxl}+wf z!MS7OGu2J5JHxrXxIrFQ;hu(Px;MGUv&p%SaDRiYLWJ`NlyB`X>#4R$+;bzk8Av0DV*muo-=6u<99vQGd$<9p5a_Z zx!96kP2_y6XS*hPwripjuE}+-QRk^uBgR*kl@6`Z`4Ns(RbE(GT2UC%Yt-nPs>)Jb*(hD1uDYbOY*bB2b;)2|O?Bbml8VCW;kv47 z_(_w04iTOfnmV+svQXEntf-`>s=TUnysk&dsFK3!;-R|Kl1g|+ufkD7%XGa)l~k9( z;|nVX>w3c*2Ui#B(#IDTJzavT$`W1G5M514Wy6CDD|NY*WyMv4OLW~wlvMT_KcYl8 zq^h!Jl&-kCq_76WEE?Y+&Cr^f5$(dl#*Q5uI=-;Ha8y-QXmM3VSax|y;iwY$uEN2B z2!+MPB_nEdHA72uYsmGHB|((00u3J_UWR5GNhxSd0}Xi z=l}c3p@qev#g$>5;D)XZPlNj^%Bd=TgBlgZu$JloD+!1H;@~Wm z>P7W{V-DP10sof^&ny+*5duFOMU95vtb+HH!rGAsl2pUBGU192DxQQ2PkNr;*1>O$ zf;Sez|JCrEQsLhw*$oDq<&eMN71b+JH8$#i1C_J@N zct4_#wK^Hps))J7SM0kEN+)aMF1b(AIj$Uwml<*9l@a^Pl$@5AD8HlcR zRByP4Tp27#tE1B4xrOki|Dpq;&k}f66|5QLyNLwkjgKTIZ_vRySqWE);Vz;R9oUv5 zggbk|8_5}2ambTu1ico6JmkB`(}#3PDdqD$=42 zS|)Op2y&C_I(UB-^f1yl$nTII4QqRoCqDYZP^uf;+0ggNJ!MV)O(bnP8ieuC?B|u# zc(I2#Lfl21E>47F+~c4A0M8E~48)7&WO)MolcG>lTv7 z&%V%a$T;PMG2KE6J`j3UDcnyqSv~67gifWJQ5$s1Nw&*ey7#oWRCg{7hHr{&7%fHJ z=1(2&)$P4ri`HN6w?2<=dH;~Gmd`JsdR`hc>(PCa>U(HrUnrT|uXp#J-MVz6EFAm> zHU5*{b={UU?=%0Wz4w5Rs@U6r&&-^&2}x)Py?2qiAxrO}g%%*8i@=fu5=sIolz<4K z_YMK+O^O22M3AP4fFK}3N^o*jt_*26s81G*Z0Cz4{+YCA0k4b+ztg+2S7xzX z;jLz!9$R+qTI9s+p~-cg4^2BM+suwlI~jh^8k+QV^i69>pOYhetRW+O?5WHiQKgUY zNqQqZUz^DGQ`&^8X7yLO2AdmqiuqHLIYVg5I zEDP?>@`vWn3N4>q%c14O8Z`(zt=w83tTOZAHjV;XL9GyOBrBp71rLj3)mjq!tJ2u7 zz65r^td+w(q80E9%9T(;C;Gk$_D(MBkg8!{Qv-UUmR1|9{JQvk<@#C!jE+!@h{jqI z)TWshhIM`mttD#I8l$KUW{!3k50RM7IzV4^(mG>}-&K1>>!!V`b=P`mJ<%SAw6)qt z+D0u+`#?La?bEhsJGFPU?b;4)g0@xLrcKrMYwu`#7}r*5W3_SGUTr>B)YG*uwe#8( zZHcxXYwF3`1#OHrN1LWC&?aghYL~U8+6HZ<_O^CZ`&iqAc_vDmiShgiM)wizn08V- zuAR_6MZ2ESPHCsL&6t~JY3H=F+UIEfNzkxy&^WRW;x-bMr*s7(5`DYwHw+k?KaAEU;A0Rr#-;x{#R)1pS16_AGGhZ zJK9}bhG;McF0AxrS#V>NKMTP1pV?S;mIHU=2I6Kh++xh~;BNJNxKyqHD~QW7;rm9b=!c+OG>;}8ZZn4|!TlO9M z9v3hF!0xgi*-z{qyN`QYe_;>Uuef3RclMAyVvliu_#f;kd&V_fJ;%A=IybljH~RYW zEZmR#^8lWeXT#;dIe1Ro_!h)-@!UKQ&&%`i{J8SDATPuV^CG+`FUE`W61*fY#Y^)t z90x-DWnPY#=M{KGUWr%cP9Dsw@DT3eRe3dDo!8(sc`aU>*Wq<}Jzk$T;0<{wZ^Rq( zCcG(c#>04X-h#K}t$1r5&fD;|yd96=k-RR?AI?Yc*Kk+%>wFX+ z&C~c8K9-N;g0JMO_-ej}ujOy@b$mVFz&G-@_}hFF-^{o0clcJmjc?~W_)h*V-^JhKyZIje zKHtmt@%{V&Kgd7eAM!)|BYv2F%#ZM+{22d)ALl3dr~D*8#ZU9k_!)kdf6mYGFZh@I zJiov%@=N?Hewkn4U-PT{8-9&n=QsFGev9Ac-}3Kpjrkq^1Ha3Ee zU-@tRcm9w+;*a?g{s(``p9xJc!G#dIFoZ++2w#y!_z8azAhL>VBD=^Ta*99^Byx$| zB9F)`@`?PSfG8*miNd0YC@PAH;-Z8oDN2dbqKtS+loc$Xb~fNi#{S&#EHJ5pXe_JhN72Cviu|w<>?}}Zt1KK0r$6jb3c0>onLGgk3P#h8;iNoS!aYP&y z$HXV%xHus`6(_|haaw#P&WN+(b8$|5A-)vn#RYLuToPZ2%i@aoT3i+1h->1yxFK$e zTjI9(R(vPE7k9)D;;#5n{3Py)`{HNui+CV@6~Bq!#Y6E(JQh#HAL6NarfWLWxh`~F zH*|;Yqxpk?Ix?At1N9oaejNV)CqsQuTdSAVt-d`V}$Lk6DKz)#&s3+;kdWt?+ zAEFP{hv}*MaD9aSnm$s0T_2^7*3iM~`{rZ3l5=qvSA`f7cRzE*!zU#G9vH|QJnxAeF5 zP5Neii~f$jRo|v>*LUbU^>_7M`g{6reUJXWzE|I;@7E9L2lWs15A{R(NBUv?WBrJJ zR6nMFq94~!=%4B*^;7z3{WJZHepdfnKc|18f2p6>FX$KbOZr#(W&Mi&wSHCqM!%+C z*Kg=I^;`OF{agJz{d@h6{)2v3|55)*zo*~Vf7XA|ALzg8zv;j05A{d-WBrN#hyGN5 zhU>GL!3}}CKn>hx?_>BHSqwkJ-v}_W8rh8OMh+vV5oiP%xs2RK9wV=j&&Y2SFbW!l zjKW3{qo`5LC~lN6N*bk%(ncBMC8MnIvQf?`Z&WZU8kLO7hSLZ(su&@L%cyEpGpZXk zjG9I*qqb4UsB6?S>KhG=hDNB-$Y^XdF`639j4-3Q(ZXnHv@%*7;YJ&ytsQ52L5yHhLLRMzj%Q^fvk!u|}NH*XU>THwGB-MuIWW z7-S?GNk+1fVhlEh7($cw+ovJT;ykCHQO;4`QNdBsQOQx+ z;dBH$syIR%E=N^IHAi(v4M$B!Ek|uf9Y@kk%4_7Y z8RgbB_zhj zBzi;!64DYxGCRe_CC6BTO_|S!Qz=9HqkxSP;-lPyW6V-oLS(k>6B3gX;*w$_V`KXF zH?y?_ksObBMUEPhSu#sXrDT@yOnBs=6nCPx6&1K8+;<2H-xx=mL5VSgV-k~MqRj?u zn;17B#y^@GncUg>Ck#=d9LaILlG_Z(Cav~~DJVNmFB|$vZ)lV|y0zP%Vv=HG`m}aa zaO>!lc#ku(kGofEH>GVGo01qAD-&eTNMQQ2N`GXp#FYNhPqCp%{ZbOU#Pw<2CxH@2 z#wPSJy;(96n=0@&#lGIEd4@+s}YXp%I$?iT$p0n8fwBccWprep1hXrMosD8+z$z+D`y_YU*u)gdpF}nrmSFa? z$LBVCwq-2N|1;t|KDXILUED+n)6?4Hp)u3aO^pvYd7GKuX0fIn-p34XWO_jtLbdS3 z4m0ic$!_X__Q{@Pjib$g#?f9m@n*vIo&+8_jZJT3k2l-|wKYTAdO|}@Z>Y!H+9M*# zlV_4Q&s0yIsUBb_6WG<`?P?0I*pO<_#q>p(Rx1;V;Y5uQnPdh>npT+GkiFE9>ewfu#bCOcAVRQ0z@c23qO*$q-x$$Tug1Xb|iBO|j zsZp(3k&Gr(=q63b(}p~4+7LS;Q_x2d2_zsQ-aSChb|zIk^JjA+WQ5n(oJLK!+uhil z5+mc?t)kuWUf#Rolg;Y1b@xw6c9YmvQE|jH659nN%k;I1iUB>rV@lsTDmDdf$`E!$ zpJ8}3l|87L``9jVzI%sT$eB5*a?s z;stkUhF{AZ^@~>C9=J>7>zBF;JjMKaEq_41&V>7LPIT)7#3=z zM~!)FR?3L0Bbn8!(cH_jj{-G)4jEGK%=o>?++q$C%xSL^&(OhpvU@W+$ z0w=6obF?)}6`qh7nnE~o$@Kx0a$Nt+pk`3Cwjfz{3Zg+M6)-SCy^{L5D<)w@(kE^J zR0}eU4;&Cb%spUOoDxJ+Nrb7cn|tOGDpYftBB;X6Bi-?Gjv#FFDpaZFRXkwIvw0Ol zdt*#ErEQ|hY?3Lni7K;+DYJ=1W)npwVoaHoLldu1%Dagv)fz)nZj&Zeh+MGMSrT3< z$BW|HsKsrQSzK?pIrCPb7J5N66{3wdH`C`$N`-A>CbGtOgi?9hWTd9%gEzCRnQ^&@ zx85n;Q)ZK1-atzBLL6nDp$~FfM0tyD4h*j>vkWgrnuE!WshnJ`GF*`vt_XLJW*M;_ z7h;>4iUzTm%&A3@*y}_*oL-TQOc$UnGQwJ9CLd-xWtw3b$o3gXj|v72fnXyt}2F^1=5$16L7n%|4aUs^@LTu{{DV}`>!k{gwssMVO2*pI~t!kL*YU%FL zC7HtDipX%aN~YA9x)7b=^u#yINCQ`9SSvGidl^PapqISSjWb+rRhLIL6puF>>GX&U z&Bz9>%rH+jtutcb^2By^_wdA`t{xW}z!M9XCoD{c5$}Pc79bA*c26dZD(!{YJsubY zglC9`2X*aY=3{y?VN^G-G^(DrI8-mq8%1?fJRWh>?marCnnR4b*y}Uf5F&|N z>|L~L2MR*k4jP4l_@1yvUKmu2C#Z!t2vqmTz!IiM2eW6fdhrBe;gTUQ9!_(C?v0fc zFoVE=4jM(GiOe80k6AcbW-|y);X$G1W+dqA%@s>F8;V9LkRz?&y}$&3txgQoU|h@uJABuWQz)7;a;bR!8kW4eKnjqAl^xPg&w zZx2(mlgFHgJTX8~j5oRL6K~eCGrZx_ogqs0jh7zOQQj07g&8R_+{hxsjl#+<@k-5b z1C!wfMs^lqWbwRilw0V>5(Os-R%`C zJBQpfSTo%b$(im}aFZl+22(s4D0E2|Zwk28aG9ENKx@fxlLBa#3=pcUx}_k>&NKQl zJsBu8Kt^_OtKk{hp)oQraA#m>Uh~iutmB@Cr}8g&>*57LUX9f$HA6XdN~J&) z*%K&7lP3^k$rC6iI8Pu3k{5|#?8p=LrNsJg$ljy)I07o-nkw z$Ay9GNi1hQuaokm#zU`pkYUnA({)2HPPV6Kt$_CQsBYPwoYt%Dx1j@ESt%LMKgH$?K#%z0Kr7hRJA8GkI{bnLH~FG?Qm3B%8?-h-UHx%4YH|mC#HP zN;7$aWixrfXeN&Yw1YPp%H&NZ+rgU*?cl+p9Xx@u9Xx?(2agNw;BlcHJTA0@$AxzA zB$n;qbyA+5I(t_%bj;FH-Tz|ZHK%flYNke^g_Nj9Y82W@iE5!np|xnaWA4krD|6Z* z@se6fB$`d};p{|_*EdRXr6ODluK$v=cMiBcczT6IM~;TdDb>1C`uy@TdSIRZp1bxQ^OY%`T!3ZPv`} zGS1`Z8G;$+IhCWmpc#^RHm3w;p3r3$Aw;E4h=n>KDs@6E)Cp0k6JnuGh)SIh3w1&) z)PYZJnGg$gLM+q?QK=JRp-zZOoe-5eAu4r3EYt~6sS~17C&WS>_^d@#sS~17C&WUX z5S2P1Ds@7-xHCH>B*NV+!A_nKl{_I9@`PB(6QYtQL?us%LY@$nJRvV2PsnrRLFal8 zc9pWeh&(QpJT41)Tq=277V@}M^0+MIajE2SS;*tEkOw}sXIvKYxGdyxspN54$m3GU z<5J1vQpw}8kjJHx$EA|TWg!oI)*`CpajE2SS;*s3$>UPV<5J1vdJ%bCDtTNM^0+MI zajE2SspN4f%uII>u&V8Odu1xY|PK6;@5NFQP@Tsj8f_T!NZ53owjrmLc~LV8qeNE43=P|x&3u;x(X)HD5%9w3%l zA23VBs%QEkJ;D?f7t*7l3RK5HNDuW4AK6=ybx@OaFwc@n(aDNr#YdRQoh#44qLwn(;63P~*`R4L`MPzpY4 zO;t*{uhLTslXJEi83N9ZQuz$pYM0NVB{0PxBR zpe?Whz;tB=&~{h>fM3=;!7v15F8FBaWQBlb)&TGf0ZKWs#j{idT(bhO-LoXXX2;^E zt+Y~1aLx*#ZMBjkZLXCBU>*{v)d2SpqCm{;wjzTz-3kBSr1t2+Q z0no940+1ZD0O*K70Z5KnL`sfzNme@t^OzMPIc7;CIcCWP9J2r<$1DJF%;E>fEPguI zQi>@#W&wa>7C)U`DG4OUtksYlvlPlat58~(PAn7|l4F)k==4HK0FGJW!7+=U&NCD| zIA$q<-$1D=Vq~F3ZOEd@+q4Gl#D8fb{3qTUc0-ysEr41y3EC4z@Q2>%a z7Lk%b5zi6G0+9r=1V{o|ash!X07)PV00gr5K_H7C1hNQ_1hN3=FjQ%25Xh2163AK& zNgxYE638Nxj(X&tS~4fXo$L;g=ZNyGN4k~FKpaa#5Xa)D10V$t;#hK)#IXiYX_XWp zjzwaa^jnCdMq>kKne5~q4gs0+u#K|g**srp|`)w7@d^Nsd_ncI_rPW&zl>8*xlk3ns@bY3$mKIA$p)IA%#<*KRmY zu$CJfvk0(hH{zHDVAF1rW7ZteTh?kwj#(gf?It;90ob*h@ClmY!Fk^sSr|isuWY#sSr|jD#X-wYKW=sG9MtAhfuc6O@*f>Gc}%?7pXiI zV(L5UO>QbbRSs!CRgS6tR0!!m zH2~bQ_8VzH6+tRc4FJ8YC6WBHg@9mIv0#`r02EX8B95})e}Uq&2dO0?O{iAb+~}zg z(uFF-RE8>qw4n+y)u9R@eW*fAg{X#@`#npaC8J$b?Ghr-u52OZmQT%#w4y~aiBTb> z7u5h#jA{UBMpcNp@lzqB8`S_(j%pf7!!F5|DoGmJs+1~2R5_#|RXL`HR3W4y)d0}Y zDuk4ziXbhi27rdvl1LibLZ||2BGAy9PnZnIRHc%JRsbnWwKnEnP!&q*QiVv4Stm?V zm@357n5q;~nJPqbOjVI`Vv`}34v`$Qf=sn(5fpDP4k=C*BBwfQiAi;;?epRSVotiXMXG5Z)I>pjcAIpSEG_*qO>Xc|`h1k`pB%Pd?Eq!KJr}QWY zAa&r_)hW@?3XwFlR?4PMQ7JpZrcUYkkWGk9oyw;~wzApPsfUKv(wW;$wO4HF6r_8u z0ycFjpSajc2O3)IY*VL1Lu*Ow>XaTL*`$DmRXNN(K6{0$JB`$QZUt7L+t8QvJP!)1=-ap1liOnam<=Xa?Dy{n>rzQiW`)?*DNS|O!hmDe0C3D2U{j~WF>5w5I$@hfHQ*)#mInTB6DlmF7z3O0tF%fuC|<4h}9!;z)j_^|Q6 zWo15md0N4j5mcHp7VPiL;xNi*6TE<-Io%Gw@(%pqR$6t=^yQJxcFqb)NTHQQMis&j zn#un*_@OZQA_UE5>Wm-hjM!#Jg5SBG~wKHo*QJH~4jh;e<6diO&5bxSBIJYy% z0grD`K&Ke_W2y1Q8oBW^-MO5BGT0}`zi~=pFL(Ulxc>cPg0n*!Lj8gqVCj(LnBc6#<9*xokvc+*zc=qOSA7keP`(XA!|n_ z)Y(60bJpFlzx1DbY;Q!xjbU{k?EL)I9tHWbN0s{(S^e9pMQaNj;a6X86>%+F&pQnY zjeIZb<@z6QzcOKOkJP@wmBhl)LF<|o`!qNyYp04QhPp!L1uhJH?{aMA4L7fSFnMaZ z52qBL(0lKg&Ycoc_Se}^X2Pq-a^$YFZ0vXK5BkL)diqg|uio>?xuC>|i}hYQU1aE; zWx+@9-7Jy+;-PKL8Z9c&V|kI8*SkNsJL2AJ8+)-C4_fE|tl@O5UBa@YvP)Mx8o z3QyhiuDjvSAq<2L|cjfJtO1!;pnWP4#!S^=Wwj-tY{9$ zGJiH4-1wQbq3ug=7#j?LG&cgm|H5A9kORpwdUMvF(}tdXO1hoti4 z{$8K+~mXB|{Xm|_Xtj@y6axCrl z+qa!J>FXOT+*mH5ikVk}5{IY53)liAu|n_CJh3$AQ0V1iEZ|#th7~ z2}1{72#;XJUio_1(KP3wKi~V0 zwZ(L3@_}Bh=O0wU`XlMVHEUKc);caKF(E0TcXBcOrqRGk!9|>fBigNeW95vVRr;L{jUIOQ zt-(jzU%3DM;_-!MEF0T<+eiIUdzCy_xX$J5Z1(N>hxS+Oy=YNvnT4lnRoEY}qjQ;q zO>g&(227vclCPCj9p1k8}Cqf@k)3XtHh6sQtC>w4WQk>5n&42PB7Y&Ubu% zma@gQPBVMPRo~Mx(5FtvXRkb7-P_N1?PsGqcKUH=?e2L-5796Dx^L5{c~7^Tc>Ua) z1roc}IrjcfzN<<&w>icg-BxT!(6}p}d1IY(^cv^rm2&(tee@#d=mn#4ymD&bk8z1h zOST)CYisN2&pug^_`lzOX@A0Tnib~V_CGM~=LPwy{;-Rc`f^ClpS$&}vTUjUC-sb3 z6K5Q)b+h>WpE}K{uw!|%BfWln^2PDmwY#pb);{iOsR8wm9^Y`;81Yr`w7Sc34D7q- zX<(ataR;89YIH4U*J5qH>ot7yhWtm$S1(<0U(AZY$)&SLt@^Efp@+qfp3D7n#9Q%= zs`&homiPA?efno@_v_w!5g+fpb;$XoSa6n!h3CCop!Mg4`I>v9z82eG`Q@Fjj&!;k z)BNLz_B*$WvVqTLp8Lsn#>ia@KH6Bl!u8ba>xNt#yj(lgxBkJ;s!jg7Vc@!|eGB%z zSoP}JLi+V}P4pvOLu$mgE|j&`yM8ODp833e{iY`hbyz#_VqmQab5fSQ`Pp*J3Lj!| zu-UUP=k|O#+(-p@fE#a+U?yW6#l=O5}ehre0K#`ce#RHasiJ;R+VbrDPiyjbJnN!X(zCnJSiBfy|k$k-UXLEko$u#SN8erZ>$e9Fo_lBN{FOeBvhPfbSoD3` z;k`LNT6f@Xzp>LJ3+`$AD4I<=nCEO-tkz&?;|GB?!XHPq`}$Gc_ij{w=Sq}M$=ZV& zxSIXaZ&T9;FC`Tz@kwa@BJFlYEIPC5RM6r4^#?fy+@DvxagWd+4jf$=U2NAu*OQfv zZw}vDxbm*Gmw#Dt<&EOmpLPyz*rCSA&7E&uztd${>5adYtDLibjiL2IUw<5wzvQ=S*d?h1=}P$zKn zy$OX1mx)L?j-|C&mBz}UJ<7aj9WMSmrk6lR7SEPEH`5LL9@{`V7uN(Yv}Zs6KZzacQkcommXSR z8BT#kk}@u78jHI2_0PtxE#LY3yH2MC*PGO<$>+XrK6J+%sU#lN2y1w9$NiVaoVoT< z$H;X%@_%~#=Dp<)JMIjd+pN@$^+hkGp8YjXYT(76XBNEU`|7rFGvAxqc~7C^^Ulnh z8}iGn%g-jh(Y+IKMuPyi6ax zoINpI?`m}W_~g0u=dZptYv@alx5uxU;RxycPWN&-vpds_5KLtS&1uZf-E`?E`1yEE zi&2LiolyL@Oc`>wWX^pxT_LV&nERa7u{LrA*Q_eN&g6f~)aO~piN7&5{^?fg#OV1o zHg#Kh|KOF&r#8-;cCOBnqEkM6b$q2)e|jhJ!N#{H_T6#eof5}-22AGj!PRHdva-TsY;EV+KqFs4WF0u^Uj4=-EQN5bmFCz=OP#O zKNfXtVc$iwySHkc^LFJkue{Q|N5rb6ifi_aZIX3r{@jB<@x8EU?ZDi(Ti=QMxPA-1yVaw~@vmTIyQ z-ts!*YbhJQ0p2YG-8M9^UtDFJx>O#Rm=K*3m7G*r>P)FJp)RY?hOmwI7zT$!jqua1 z^}T*4A0Oh8EAKFRfm|FDJ|{5QHWSdaS>ybV^J=`&Ej`z(?c?LF@FTajJwEe6>$fX! ze7$|v3srXh-uGtK$HfZ_skbIJb^E-Lle^t-bZE>QF|SQ*+ipZ!t_Ne1zF4`h+c7@y z(=z?@z8{fm&7=eGUSDy1Maq&{gX$JM&{6BS^8rCBwCI%jEq@8{qNMc>z_zY-WDzV&Z+YDK{0g?rB*TzAShcj@9gc6ORn==3Y& ztFAfr$A$@a)>p6pZliwJirs7S{>V+=-s`Y+McDf>`yyTEkKT4f>5ifCZJ&khdE>iA zT_;?8+i%o^E+196e&h95THL5I>~4v1vjZw_Yx~OK4;pmtyz#RW*D4=8ac97?>cfK5 z^iMEte#{wjj^5$?Z&^=qb%M`46JI|1wli0TLM_XJeFRS5parCw^_-|@5y1hVeibndUpP?vR@r|IBMsevw=fS zY`*pA>hDEXyg9hd+}8PTIRB_J&p3JEn@#V(UF7VAGKGCU`mA=?H+3fNyHGbet^eEo zc%?667eA}tDtJ|kdA)K3RQUPH&xwgiLx$=5o;A4>dAec6Ut@Wr<;K&Gmhe3lUFh4p9ZR=}tl4cre4oT8m)u)Yc2~F*F!R9G zCR-Ypo_u3P_{lrXub)4k_jc_fBd%t-cDQyG$CPQ!tKPg6ynf#WeafRzF=w6*tNiw{ z$v3Wi@@4eq`kH@y>dkwmPKjoab}M>g45U?1l^YO?(=Us)Z{sy zJ4y|3_?lefSZ^%v;>?#>AQ9}J;qhUhUIr_T;OtW0L%~;(M;;+i@I^CY_257~N3q2R z?$oxXSi2B(x> zbM^Zy8@3L4^x>%%Q(oJ1{=l^gN1B&(2%@Pr@^7iDGhuYoT^V7|7S?`S~Uba@Y;}d@y z`1P$HHm-ZT{k?>@lgBOlq0!(c$tN$qoD^Po^_bH;W{G8QRM@hDsK~*J!d;9NgS$8@4|fHIJ0o$!OhvdWF(=%?tP0#A40jpgmYJS# zyIC^aDJ%u$*Yi1`}BJrB1vA-*+G z41+oHX#&4`o*!kD3sd>*&oupT7twf)H*VUhz4=2ucuFV{8lDs~)WYkQ_a-q-xO)vA zpyle-f5-r>fV^`MHyFuqKbW%SG6nV>%FRjs6rS~kaej2GQeNDMR0Fpjh2dtS&bYlO z7WV=T)kfjYp_y7bZVFn5+lzK-2XM#GDcn!=HJ)V3e0F*4N2ZN7qi{CgV_!CHUdCg` zdhDIJ(+TMXH*MT+N&bLPGd}A9kNpIB0GAEVOr@Q(tZ4@Zn0D@ork%HpY3Dm;+67`g z_F~g6IN4(_N4qf8Qr?XUdH7^STlo+>G~xmW3o4=alF{cn^{OEbh#Q*2n1RfkG>D!kF-4+%bSSLO3%mZPSiAn|0jMp%4L4O`;RQ&e`1^cRVI_m zyK`O7^(#JJb0)ce$}nf<8k=h-{>SE8=P~CiZZ&*nu1&zGv8G!MG4aobSmnsP=I5GB zIZAWg-*C^Qa1*KG&2Tl98S=Nn7emfs{AZf7g!6NK_m}xs3;fjr|9lHT6aV?z^R{Qj zE!T>FQ*UEsep@qWWnKyEsZgws8P-!5aIdSD18d;_k9s+AMzuLavnMF3r2Y%8fJvJ7_ng%KZzYoeS3gnl4I+5?Eo^6g9C= zsV!<_U0hGp!+J}~D5S{91GL;p%QcyFeXCcJE{>fwcA(mmd@uY|d3Zqk>+@F|{I_%9 z@0l&t^NB+k6_vH@PY1s6qy2%A`5*rIA7*^diHzqQMKsnp?oP%T-e3Qo7Wjbu_5W83 z{M{`8eflq+H{v{;YrAlpd&WO*qyOvw-`xWLpjKcb(Mx-9qM!Ny?=InA^1Uta=Y3^4 z8#hPPvl^NIPtX4K|L<-AjE_Ixm1eH97@kmZJQw@to-VwPbX~R2{tN&AmFFI^#r*Jj z(SDHH^Vwp=dUtyA=^QqVn)b9Dne@!>kH*sdjur-A8()IXd7wrkYR2sl?u|ljA)|D<+hc#xcSbNqDd-(q7BlHU^ zOZ8>En8&W?d8S*$V~_OMznZo_-ozQ6bVg@0+=%tqX&!r%X**_{woe0(z00(H%b9X~ zJ@Ru`FvD|)d+eoVeC|W0|K+>XqpX}fQ9!xN%{1-uvhCq7FV`=yD}y(9Xdm)4h_ zu8%}7Ovdx|`RE7EYO<9uL&%#2TSb!&n$(wPvj$If6ytnR5@;1E)e!ED9$< zF)R*qn%5 zkc=nNa1DS;{LYWOt$~PngQh!CRE98>jpU$S`VtbG>o?#Bf z+0Z;81~F(?&E@HNHY z>@&3Q=j?MWkbS|v(1O@`b{_3;8F|a|HIDIF7k$`3xom|r%rf0jf;@6$;FIrvwFG~wTsmgFk$AI3_5#ifsbu+4GQWSI7i9YfQu_x{ z`^z^0tnL5e-2ahKd4h_k&NL=GJ5C z-j~{q8vk=Esek6(j(zb!@+IYLWTbeYMR-rt*Fd$HvZr29mzYHv-ep*QOe)? zECBmbHi}Kayf=wWg+`gqWS+_0{{hxZWx(jkY&*NmeR&n$jgRNM_%|Xz zR1>jy2kU)tQ!l2s(3AD0`YHX9QOf9Oj50PD*B#v*6CGQ9>iG2bnd!@YEBJ=`4)#s+ zo$kBHcZ=^~-7snfEY%%wNxrAel&X)3EiQ{Wwy1CSx=~xADtQQQ- zN`aV5ifE-co(3Tm*7HbJ1!gpV6Q-L>9h(k)XU)SOp^JF{%q1M}+#!z;n9+P4OgEPr zIUVZ|j+Kd;M^(bCC(Ln);dfvz7P6E}EXZmIiRK$%y7>v1>HG(ni-jzu65Zv7nk*WY!?$TZBAG$R7zQ>tdBz6Cu(3ZJ2H@*AwadCzy)`)z>0Z)-jrI zBBWe%r1N`(lx<>_iRWQJZieaRa$SO}Fw^-1 zn2Rknqr7+nLT)4EIYRzQNNag1AKs9#+X*YzTIu{ZiKR8Dl?Sp0(R>FX<$5fg|1Oat zCroR3DTD{gST3IqMDv|6-CVBU(kM*V<1%+%xL}|OgFy-(^{J56uX;Zzml>1A z*2G%F76^&vat-X}avhqEx6>q2*2m6^mI#aHa;5C%aE_pArbFdPB(=DG4r2EZH*5>G(++nNpT@33lEzu3oTni`C+_WOfs%5$s!-Bbl6$MzOmvN3)+` zj$v{vO=gnIQ<-e<8BEr67B^t7IAA;%Tw_v8@ z+oTd%2&NU;0U^=+BbaVH6_-dI=3-G8<`SGCl)~Q1iSvs(S}5M|4#!DASFI<$kJ4XD z#C{9=P$uP%VBf(U$$o%2irt4fmOX$unaR;Ml}VmWXOa;!nCy{RT(;R9E=x6+Q>nPj zaVhTzGnyZU>E^PP((xr6l*cmuiXdbO?*ucNe+<*jW$x+10n>`?Oqe5t`JOO75`&ei ztX(##js8~snC#mTOt#5LCew~$Kf)Zteug=j{R(p`lOt_9ll+~9qX1bW*&}ngY{U64 zV26%9Z6M~}5_~w`w%#iC@)5AN!F~<)cGz8D?-YBn>cG6mM|x1Nn<#39T+qLzFmq$A zf|Q&1C=X_|iQ#Fmr6d>ZU65pI`Aqj16G!1=J#I6%ai))B^-U?wP`t-VC7b{oCB_QL z7aBSr$2T6}-^3@H;e3*b!|vN#$H|_UDP|0x3R{+}8EjcHQ~op$YPyM{a<%dxWZ7rH zjZ_|VZ}^r%#!OGBAJ!v9pvfwMqxJ9&sc@W=bmy-ib^%b`VK0R3hP?=O6zn%($G~0! zI}Y|L*!^LzfsJz!@g{7X3yAfwQ(?=J=EHcdg%QvK@7#B#Q;}@RuTc8!ruet=NX|H^>~f@|Vco#<71x z&RhA*WN+j6j)~X+Tt%|C@@}wY&3eF=HR}ai)~pX~*)Dxy%NpVIfNGQgdoBEfV9T1} z`zTbi!LT>LkME?2jj(6ImOPvdTeh}r`8j47dbewk2T8T9$*o0d{$^ zxADrbH-al&Vaw9L3R{-GCu~{zXxOqpVqweD_k%6_BObOaeIjhxA1Sb9sfWOpr5*T8_)Es9vf5&N?C@Mm+lW8WJYvn{c(N3!tBKfgRR-eV2Yg ze_|9d8W_EdvBp~Ci1D2xz){K3){*F#>)7e|((%ZrkWW3Io<3?4p{A>Al^B?KI z-2Vgr>j92{vH>ju`Ugx8*b?wrz%N(!bu3MLM#aAMj)`-2O^jdSbbO!SmywNRXz|^2!U0H1%Y(~5Lnv- z5kd&811t!vl7PS(9S9d8p#LohXlEb-2~m|0(Eb(#)>uFU5uzF)y4Vm{=K%q(BbVxg z=xRe?Z39GZLewC{D>eky2SDT@L`_0;vmt&VL|#JFBE+jU1lCTFk&h6y3DMn#!1@CS ztX5IdI)vz9L;OYvtV)5XONgE}#P5W_>Jf-~gmBvsSl=KQtj~a`Pl#SN1XgxH6edIi zLPXgRj|qVl7i2UfM6?a@gb+mu5lV;{8{!W_6eC0ft4_FDMyIbgcxW;_!6Q#A;JkU z$cD&5hzf*gLx@Bh!jBLY3DK4iNj8K(Au17~9U+o!h(E7w3Q~Nbr<065FfZztVJb77 zK9~>-3GqH5uGkPm2(gF|dkOKi4Kb7uZxCW1A+FjG!w9jM5c>)7jSZ1Xh$VzLK!|HL z#Bf3^CB#8OT(==c5Mmi2J|M&m8{#!WEGNW=gt%!#j3mSgLL4H*EgRx>LaZdjM})X- zLyRKCDncA4#J4uYXhN(e#K(mA&W1=M#2P{zA;kAK#27-XCB#ueNPVLA?N~y*Nr+>F zkorVLj3dN4LVQ99sZUhIctWfv#BoAMeWD^J5Ml!%P7p%s6BRL$5E}{cDIugjQ4x~} z@fIOY5<=<|6)~9*ZxiAaA*4Q05mN}Ui4dm=A@zxhm`aGvg!qgQQlF@ZX@uB9h%1|q&`s*a|!V-AubR?>Jt?)j}W^Eagh*DZHW1Vc#ja52=UB@ zSU`x~g!oD#@U3ENE`M5EgCWHxqm-0kG=sU-=nGR>j~Sd0r3n#6h<-MNAVe8LG$%xV z8$u_IWE&!Y5S0lLL5LI^A}b-Bgoq@>U>hPE zA%Y3fo)AN9i0p)@LWmB8korXJ+Z==lAw)+)NPVIrauUKth)#r%`b0$p5~3<0IukJt@_pAdBj(Ss0DTd0Tvgs4l1o`jIvLPZoLL_I>d2_dzGiYP*ehJ=VFgq-PB zL{UP75+a5Wa;8@i#R$=e5WNW@XL=P;oDhu((T5O|ZHN+tXhMisLQJtCN)ns%z z)rQDNh}wkcPKePqL?J@dCqyqojI$vM6QThjq6jhGhL{Hb7seKtw~aF}FB-qXROVA- zIw9U6#92c8W<$&%#8yIlPKe)ah?#`gMu>BScxXe+BE)t=d_jmuHpFZ~>>$LKgm`R2 z%pt^1LYybW6B}YKA>Jj#1w#B`L(C(@E<#)+#8Vq$J|W&C#3e#Jvmq7`VmBeak_h~) ziM37B39*L|mkGgah=qiBpAc6F!EJ~|gxE`nuL&V+h&Kqaj}TW0A+?3tw~Gm}pAg>= zLTU>Yv4ju@2yu-NQd_8qrGz+0i0g!q+CoJvBg6-UxIqZ1EmXvELVQSwn}m?sLPe|~ z#34f5B81cyDqJ))L|H-zgve?`tRuuHgt$wHoHoRI zLL4W=kAw&$1l|S0`Db682`1q8Yf9nV@ogfmJ#eo5zSavTujjOW;!ANsOA}w= z_j$(A`C>7g9LlHS=DS_;?Ow(@lG#h1GcxbDTXE`1-)Q9st)}H;``AY~VLZbwu&Zo8 zJHQUI!|Y>rgq>iYvXksA-n~1=F0qU3EA|b$hHt|DzxC$c|3C9)c;1p1zl2i*&m$Y) zozF15CDayY`knF4U{AcE9E<0Y1M!yfP`sy{rj5t*nd#`SefWHY&vAUt;Bx_=tN854 z=Kwwj@xfc&c>9yFBlw)a=Tm%6;&T?C&+&l|cB$ns4*yxAswo#E@Oy$m)F#0h<(ND|2+MGO{0#85Fzq>ABUgm_Ji6tCmt zezZsvW5if74kz0a#6&SkOcqnbR549V$BF(-F$=yqVy>7c=F@LG{qt{h$r3daVWPQc zAzF%7qO}MYZA4o8|3vx1JMw^M);K_{CX3frTi-;>Zp~)6N#IcZw}%a z&xhiW_(&WUAB!X6D4sfeB94m_;!`|DIwely8POR${rOy+!}E+U#d&c7&wnmqcDgLC zh_A&}@r}49u4Ar}<@smdf|KRhFZN 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() From 02465119200f933c658d28af17a1d3ce48fd7115 Mon Sep 17 00:00:00 2001 From: kunchok tashi Date: Sun, 20 Sep 2026 18:40:35 -0400 Subject: [PATCH 2/2] agent flow added --- .gitignore | 4 ++++ AGENTS.md | 15 +++++++++------ CLAUDE.md | 15 +++++++++------ app/build.gradle | 5 ++--- 4 files changed, 24 insertions(+), 15 deletions(-) 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 index 5245f16..8259f26 100644 --- a/AGENTS.md +++ b/AGENTS.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) @@ -150,10 +150,13 @@ never price one plan by converting another plan's price from a different currenc ### 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 @@ -170,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/CLAUDE.md b/CLAUDE.md index 0492007..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) @@ -150,10 +150,13 @@ never price one plan by converting another plan's price from a different currenc ### 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 @@ -170,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/app/build.gradle b/app/build.gradle index e671733..e953f82 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -26,14 +26,13 @@ android { // 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'}\""