# FocusFort — build spec

A screen-time blocker that prioritizes bypass resistance, targeted settings protection, and reliable paid activation instead of easy-to-defeat schedules and account-gated premium failures.

## Project context

This spec describes an independent, alternative Android app you are building from scratch to compete with an existing incumbent app on Google Play — not a modification, clone, or reskin of the incumbent's own code, assets, or branding. Use the incumbent only as a market reference (via the report data below), and design working_name/package_id/store_listing/design_system so the result is clearly its own product.

## Incumbent app

- **Name:** AppBlock - Block Apps & Sites
- **Package id:** `cz.mobilesoft.appblock`
- **Google Play:** https://play.google.com/store/apps/details?id=cz.mobilesoft.appblock
- **appy.fyi report:** https://appy.fyi/report/cz.mobilesoft.appblock
- **Category:** Productivity

## Overview

- **Working name:** FocusFort (trademark cleared: no)
- **Package id:** `fyi.appy.focusfort`
- **Min / target SDK:** 26 / 35
- **Backend:** firebase
- **Estimated build time:** 3.5 weeks
- **Pricing:** subscription, $2.99 via `revenuecat`
- **Runtime AI:** none
- **Permissions:** `INTERNET`, `POST_NOTIFICATIONS`

## Non-goals (out of scope for v1)

- No iOS, desktop, or browser-extension version in v1.
- No parent/child remote monitoring, family dashboards, or child-targeted marketing in v1.
- No cloud sync of block lists, schedules, browsing history, usage history, or bypass logs in v1.
- No VPN-based network filtering in v1; website and keyword blocking are implemented through Android Accessibility text/URL inspection.
- No one-time lifetime purchase in v1 because the report's revenue funnel assumes a $2.99/month subscription.
- No attempt to block every Android Settings screen; v1 only protects settings panels directly related to bypassing blocks.

## Tech stack

- **Language / UI:** Kotlin, Jetpack Compose
- **Kotlin:** 2.0.21
- **Compose BOM:** 2024.10.01
- **Gradle:** 8.9

| Purpose | Gradle coordinate |
| --- | --- |
| Compose UI foundation through the Compose BOM | `androidx.compose:compose-bom:2024.10.01` |
| Material 3 Compose components | `androidx.compose.material3:material3:1.3.1` |
| Compose Activity integration | `androidx.activity:activity-compose:1.9.3` |
| Compose Navigation graph | `androidx.navigation:navigation-compose:2.8.5` |
| Lifecycle-aware Compose state collection | `androidx.lifecycle:lifecycle-runtime-compose:2.8.7` |
| Compose ViewModel integration | `androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7` |
| Android Kotlin core extensions | `androidx.core:core-ktx:1.15.0` |
| Room local database runtime for block targets, schedules, sessions, and bypass attempts | `androidx.room:room-runtime:2.6.1` |
| Room coroutine and Flow support | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor | `androidx.room:room-compiler:2.6.1` |
| WorkManager for schedule transition checks and notification refresh work | `androidx.work:work-runtime-ktx:2.9.1` |
| RevenueCat subscription purchase and entitlement state | `com.revenuecat.purchases:purchases:8.10.0` |
| Firebase anonymous authentication for reliable account activation state | `com.google.firebase:firebase-auth-ktx:23.1.0` |
| Firestore entitlement mirror for paid activation recovery | `com.google.firebase:firebase-firestore-ktx:25.1.1` |
| Kotlin coroutines on Android | `org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0` |

## Design system

- **Primary color:** `#1E5B4F`
- **Background color:** `#F7FAF8`
- **Error color:** `#B3261E`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `shield` (regular weight)
- **Theme notes:** Use Material 3 light and dark themes. Light theme uses #F7FAF8 background, #1E5B4F primary, #26332F on-surface. Dark theme uses #101816 background, #80D4BF primary, #E1EAE6 on-surface. Use rounded 16dp cards, 8dp spacing multiples, and high-contrast red error banners only for active bypass or activation failures.

## Screens

### OnboardingPermissions
- **Route:** `onboarding/permissions`
- **Purpose:** Guide the user through enabling Usage Access, Accessibility Service, notifications, and optional uninstall protection before blocking can work.
- **Reached via:** app launch when required special access is missing; tap Permission setup from SettingsHardening
- **Key UI elements:** Step list with Usage Access status; Step list with Accessibility Service status; Step list with notification permission status; Step list with uninstall protection status; Open Android settings buttons for each missing capability; Continue button enabled only when Usage Access and Accessibility are enabled
- **States:** loading_permission_status, missing_required_permissions, optional_uninstall_protection_missing, all_required_permissions_enabled, error_opening_settings_intent

### SubscriptionActivation
- **Route:** `subscription`
- **Purpose:** Sell and restore the $2.99/month subscription and show whether premium activation is locally and remotely verified.
- **Reached via:** tap Upgrade from Dashboard; tap Subscription from SettingsHardening; automatic route after onboarding when no active entitlement exists
- **Key UI elements:** $2.99/month plan card; Subscribe button; Restore purchases button; Activation status row with RevenueCat status; Activation status row with Firebase status; Offline/local entitlement fallback explanation
- **States:** loading_entitlement, not_subscribed, purchase_in_progress, subscribed_active, restore_in_progress, firebase_activation_delayed_but_local_active, error_purchase_failed, error_entitlement_unavailable

### Dashboard
- **Route:** `dashboard`
- **Purpose:** Show current block status, active schedule or focus session, configured targets, and recent bypass warnings.
- **Reached via:** app launch after onboarding and activation; tap Dashboard in bottom navigation; return from BlockIntercept after denying access
- **Key UI elements:** Active block status card; Next scheduled block card; Start focus session button; Configured targets summary; Recent bypass-pattern warning banner; Bottom navigation to Targets, Schedules, and Settings
- **States:** loading, empty_no_targets, empty_no_schedules, populated_idle, populated_block_active, populated_focus_active, error_database_unavailable

### TargetManager
- **Route:** `targets`
- **Purpose:** Create and manage blocked apps, websites, and keywords.
- **Reached via:** tap Targets in bottom navigation; tap Add target from Dashboard; tap target summary from ScheduleEditor
- **Key UI elements:** Segmented control for Apps, Websites, Keywords; Installed app checklist; Website pattern text field; Keyword text field; Allow-list indicator for targets excluded from blocking; Save button
- **States:** loading_installed_apps, empty_no_targets, populated_apps, populated_websites, populated_keywords, error_installed_apps_unavailable, error_invalid_pattern

### ScheduleEditor
- **Route:** `schedule/{scheduleId}`
- **Purpose:** Create or edit recurring block schedules tied to selected targets.
- **Reached via:** tap Add schedule from Dashboard; tap existing schedule from Dashboard; tap Schedules in bottom navigation
- **Key UI elements:** Schedule name field; Day-of-week selector; Start time picker; End time picker; Target selector summary; Timezone tamper protection explanation; Save schedule button
- **States:** loading_existing_schedule, new_schedule_empty, editing_populated, error_invalid_time_range, error_no_targets_selected, error_database_unavailable

### FocusSession
- **Route:** `focus`
- **Purpose:** Start an immediate blocking session without waiting for a recurring schedule.
- **Reached via:** tap Start focus session from Dashboard; tap Focus in bottom navigation
- **Key UI elements:** Duration picker with 15, 30, 60, and 120 minute choices; Target selector summary; Start focus button; Active session countdown; End session button requiring written reason
- **States:** idle_no_session, starting_session, active_countdown, ending_requires_reason, ended, error_no_targets_selected, error_database_unavailable

### BlockIntercept
- **Route:** `block/{attemptId}`
- **Purpose:** Display the blocking wall when the user opens a blocked app, website, keyword, or protected bypass setting.
- **Reached via:** Accessibility service launches route when a blocked app is foregrounded; Accessibility service launches route when a blocked website or keyword is detected; Accessibility service launches route when a protected Android Settings panel is opened
- **Key UI elements:** Blocked target label; Reason the block is active; Countdown until schedule or focus session ends; Written reason text field for temporary access; Grant 5 minute access button; Stay blocked button; Bypass-pattern warning if timezone or protected settings were touched
- **States:** loading_attempt, blocked_no_temporary_access_available, blocked_reason_required, temporary_access_granted, bypass_pattern_detected, error_attempt_not_found

### SettingsHardening
- **Route:** `settings`
- **Purpose:** Show hardening status, subscription status, and exact settings panels that are protected during active blocks.
- **Reached via:** tap Settings in bottom navigation; tap hardening warning banner from Dashboard
- **Key UI elements:** Usage Access enabled row; Accessibility enabled row; Uninstall protection enabled row; Protected settings list; Subscription status row; Restore purchases button; Open onboarding permissions button
- **States:** loading, populated_all_hardened, populated_hardening_missing, subscription_inactive, error_status_unavailable

## Data model

### BlockTarget (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| targetType | `String` | one of APP, WEBSITE, KEYWORD |
| label | `String` | display name shown in UI |
| packageName | `String?` | Android package name for APP targets, nullable otherwise |
| pattern | `String?` | domain substring for WEBSITE or lowercase keyword for KEYWORD, nullable for APP |
| isAllowListed | `Boolean` | true means the target is explicitly excluded from blocking |
| createdAtMillis | `Long` | System.currentTimeMillis when created |

### BlockSchedule (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| name | `String` | user-visible schedule name |
| daysOfWeekMask | `Int` | bitmask Monday=1 through Sunday=64 |
| startMinuteOfDay | `Int` | 0..1439 inclusive |
| endMinuteOfDay | `Int` | 0..1439 inclusive; values earlier than start mean overnight |
| createdZoneId | `String` | ZoneId.systemDefault().id at creation for display and tamper comparison |
| enabled | `Boolean` | false disables this schedule |

### ScheduleTarget (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| scheduleId | `Long` | foreign key to BlockSchedule.id |
| targetId | `Long` | foreign key to BlockTarget.id |

### FocusSession (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| startElapsedRealtimeMillis | `Long` | SystemClock.elapsedRealtime at start |
| endElapsedRealtimeMillis | `Long` | SystemClock.elapsedRealtime when session should end |
| endedAtMillis | `Long?` | nullable; wall clock millis when ended early or completed |
| earlyEndReason | `String?` | nullable; required if ended before endElapsedRealtimeMillis |

### BypassAttempt (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| attemptType | `String` | one of BLOCKED_APP, BLOCKED_WEBSITE, BLOCKED_KEYWORD, TIMEZONE_CHANGED, PROTECTED_SETTINGS, UNINSTALL_PROTECTION |
| targetLabel | `String` | app, site, keyword, or settings panel label |
| wallClockMillis | `Long` | System.currentTimeMillis when detected |
| elapsedRealtimeMillis | `Long` | SystemClock.elapsedRealtime when detected |
| temporaryAccessGrantedUntilElapsedMillis | `Long?` | nullable; set only after valid written reason grants access |
| writtenReason | `String?` | nullable; user's reason for temporary access |

### ClockGuardState (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Int` | single row primary key, always 1 |
| lastWallClockMillis | `Long` | last observed System.currentTimeMillis |
| lastElapsedRealtimeMillis | `Long` | last observed SystemClock.elapsedRealtime |
| lastZoneId | `String` | last observed ZoneId.systemDefault().id |
| tamperLockUntilElapsedMillis | `Long` | elapsedRealtime deadline until which active blocks remain enforced after clock tamper |

### LocalEntitlement (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Int` | single row primary key, always 1 |
| isActive | `Boolean` | true if RevenueCat reports active subscription or unexpired cached entitlement |
| productId | `String?` | nullable; subscription product id |
| expiresAtMillis | `Long?` | nullable only when RevenueCat does not provide an expiry |
| grantedCapabilitiesCsv | `String` | comma-separated immutable capability names granted to this subscriber |
| lastVerifiedAtMillis | `Long` | wall clock millis of last RevenueCat or Firestore verification |

### UserProfile (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| uid | `String` | Firebase anonymous auth uid, document id |
| revenueCatAppUserId | `String` | RevenueCat app user id linked to this Firebase uid |
| createdAtMillis | `Long` | server-written creation time in millis |

### EntitlementSnapshot (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| uid | `String` | Firebase anonymous auth uid, document id |
| isActive | `Boolean` | current active subscription state |
| productId | `String` | subscription product id |
| expiresAtMillis | `Long?` | nullable if unknown |
| grantedCapabilitiesCsv | `String` | capabilities granted at activation time and never removed by app version changes |
| lastVerifiedAtMillis | `Long` | last successful entitlement verification time |

## Features

### Reliable subscription activation without login dead ends

Activate the $2.99/month subscription through RevenueCat, mirror entitlement state to Firebase, and keep a local entitlement cache so a Firebase login timeout does not block a paid user.

- **Answers complaint:** Account/activation bugs Login and account creation that times out and blocks premium features from ever activating after payment

- **Screens:** SubscriptionActivation, Dashboard, SettingsHardening

- **Estimated hours:** 18

**Implementation notes:** Configure RevenueCat with one monthly product id, `focusfort_monthly_299`, and one entitlement id, `premium`. On app start, call Firebase anonymous sign-in; if it succeeds, pass the Firebase uid to RevenueCat as the app user id and write UserProfile plus EntitlementSnapshot to Firestore after every successful CustomerInfo refresh. If Firebase sign-in or Firestore write times out after 8 seconds, continue using RevenueCat CustomerInfo and the Room LocalEntitlement cache. Premium is active when RevenueCat reports entitlement `premium` active or when LocalEntitlement has `isActive=true`, `expiresAtMillis` is null or in the future, and `lastVerifiedAtMillis` is less than 72 hours old. The UI must show `firebase_activation_delayed_but_local_active` instead of blocking access when RevenueCat is active but Firebase is delayed.

**Acceptance criteria:**
- After a successful purchase, Dashboard is accessible without restarting the app.
- If Firebase anonymous sign-in is forced to time out but RevenueCat returns active entitlement `premium`, SubscriptionActivation shows the local-active state and premium screens remain unlocked.
- Restore purchases refreshes RevenueCat CustomerInfo and updates LocalEntitlement within one foreground session.
- If both RevenueCat and the unexpired local cache are inactive, premium-only actions route to SubscriptionActivation.

### Capability-based entitlement ledger

Store granted capabilities at activation time so already-granted core blocking capabilities are not silently removed by later app versions.

- **Answers complaint:** Billing moves the goalposts The dominant cluster: features early one-time-purchase buyers already paid for get moved behind a new subscription

- **Screens:** SubscriptionActivation, SettingsHardening

- **Estimated hours:** 8

**Implementation notes:** Define the v1 premium capability set as `APP_BLOCKING,WEBSITE_BLOCKING,KEYWORD_BLOCKING,SCHEDULES,FOCUS_SESSIONS,ALLOW_LIST,HARDENING`. When entitlement first becomes active, write this exact CSV to LocalEntitlement and Firestore EntitlementSnapshot. Feature checks must test whether the capability exists in `grantedCapabilitiesCsv`; app version defaults may add new capabilities for new users but must never remove names already stored for an existing uid. If Firestore has a capability name that the local app does not recognize, preserve it unchanged during writes.

**Acceptance criteria:**
- An active subscriber with `ALLOW_LIST` in LocalEntitlement can use allow-list UI even if a later in-app default capability set omits `ALLOW_LIST`.
- A Firestore EntitlementSnapshot containing an unknown capability is read and written back without deleting that unknown value.
- The app never gates app blocking, website blocking, keyword blocking, schedules, focus sessions, allow-list, or hardening separately for an active v1 subscriber.

### App, website, and keyword blocking engine

Block selected apps, browser websites, and visible keywords when an active schedule or focus session applies.

- **Answers complaint:** baseline parity

- **Screens:** TargetManager, Dashboard, BlockIntercept

- **Estimated hours:** 34

**Implementation notes:** Use UsageStatsManager for periodic foreground package confirmation and an AccessibilityService for immediate foreground-window events. APP targets compare the foreground package name to BlockTarget.packageName. WEBSITE targets inspect AccessibilityNodeInfo text from supported browser address bars and visible page text, normalize to lowercase, strip `http://`, `https://`, and `www.`, then match if the normalized string contains BlockTarget.pattern. KEYWORD targets normalize visible AccessibilityNodeInfo text to lowercase and match whole-word occurrences using Unicode word boundaries. If a matched target is not allow-listed and an active schedule or focus session exists, insert a BypassAttempt and launch BlockIntercept using an Activity with `FLAG_ACTIVITY_NEW_TASK`; then send the user to the launcher home intent when they choose Stay blocked.

**Acceptance criteria:**
- Selecting an installed app package in TargetManager creates an APP BlockTarget with the correct packageName.
- Opening a blocked package during an active focus session creates a BLOCKED_APP BypassAttempt and shows BlockIntercept.
- A website pattern `example.com` blocks visible URL text `https://www.example.com/news` during an active schedule.
- A keyword target `games` blocks visible text containing `games` as a whole word and does not block `endgames`.
- Allow-listed targets are not blocked even when they match a schedule.

### Recurring schedules and immediate focus sessions

Let users create recurring day/time blocks and start immediate timed focus sessions.

- **Answers complaint:** baseline parity

- **Screens:** ScheduleEditor, FocusSession, Dashboard

- **Estimated hours:** 20

**Implementation notes:** Schedules store local start and end minute plus a day-of-week bitmask. A schedule is active when the current local day matches the bitmask and the current minute lies inside the range; overnight schedules treat endMinute earlier than startMinute as spanning midnight. Focus sessions use only SystemClock.elapsedRealtime for start and end so wall-clock changes cannot shorten them. WorkManager schedules a lightweight check at the next known transition to refresh notifications and Dashboard state, but the blocking decision must always be recalculated synchronously by the blocking engine on every Accessibility event.

**Acceptance criteria:**
- A weekday 09:00 to 17:00 schedule is active Monday at 10:00 and inactive Saturday at 10:00.
- An overnight Friday 22:00 to 06:00 schedule is active Friday 23:00 and Saturday 05:30.
- A 30-minute focus session remains active until `startElapsedRealtimeMillis + 30 minutes` regardless of wall-clock time.
- Dashboard shows the currently active schedule or focus countdown within 1 second of app foreground.

### Timezone-change bypass hardening

Detect wall-clock or timezone jumps near active blocks and keep enforcement active using monotonic elapsed time.

- **Answers complaint:** Blocking is trivially bypassable A schedule can be defeated with a simple phone-settings timezone change

- **Screens:** Dashboard, BlockIntercept, SettingsHardening

- **Estimated hours:** 16

**Implementation notes:** Maintain ClockGuardState with last wall-clock millis, last elapsedRealtime millis, and last ZoneId. On every app foreground, Accessibility event, WorkManager schedule check, and Dashboard resume, compute `expectedWall = lastWallClockMillis + (nowElapsed - lastElapsedRealtimeMillis)`. If `abs(nowWall - expectedWall) > 5 minutes` or `ZoneId.systemDefault().id != lastZoneId` while any schedule would have been active at the previous trusted wall time, insert a TIMEZONE_CHANGED BypassAttempt and set `tamperLockUntilElapsedMillis = nowElapsed + 2 hours`. While `nowElapsed < tamperLockUntilElapsedMillis`, treat matching schedules as active even if the new wall clock says they are inactive. Update ClockGuardState after each check, preserving the tamper lock deadline.

**Acceptance criteria:**
- Changing timezone during an active schedule creates a TIMEZONE_CHANGED BypassAttempt.
- After a timezone jump that would otherwise move the device outside the scheduled block, the blocked app remains blocked for at least 2 elapsed hours.
- A normal 3-minute network time correction does not create a TIMEZONE_CHANGED BypassAttempt.
- Dashboard displays a bypass-pattern warning after timezone tamper is detected.

### Targeted settings and uninstall hardening

Protect only bypass-related Android settings and uninstall flows instead of blocking unrelated settings screens.

- **Answers complaint:** I dont like the idea of blocking all of my phones settings that have nothing to do with my blocked apps.

- **Screens:** OnboardingPermissions, SettingsHardening, BlockIntercept

- **Estimated hours:** 22

**Implementation notes:** During an active block, the AccessibilityService watches foreground package `com.android.settings` and Activity class names or visible titles. Block only settings panels matching date/time, timezone, accessibility service management, device admin, app info for this app package, battery optimization for this app package, and uninstall confirmation flows. Do not block Wi-Fi, Bluetooth, display, sound, storage, accounts, or general settings home. For uninstall hardening, offer optional DevicePolicyManager device-admin activation; if enabled, the app can receive disable/uninstall attempts and route to BlockIntercept while a block is active. If device admin is not enabled, show a hardening warning but keep normal app blocking functional.

**Acceptance criteria:**
- Opening Date & Time settings during an active block shows BlockIntercept.
- Opening Wi-Fi settings during an active block is allowed.
- Opening this app's App Info screen during an active block shows BlockIntercept.
- If uninstall protection is not enabled, SettingsHardening shows `populated_hardening_missing` but Dashboard and core blocking still work.
- If device admin is enabled, an uninstall or device-admin disable attempt during an active block creates an UNINSTALL_PROTECTION BypassAttempt.

### Written-reason temporary access gate

Require a short written reason before granting temporary access and record repeated bypass-pattern signals.

- **Answers complaint:** Harden against basic circumvention before adding features. A timezone-change bypass documented in the single most-upvoted review here undermines the entire product.

- **Screens:** BlockIntercept, Dashboard, FocusSession

- **Estimated hours:** 14

**Implementation notes:** On BlockIntercept, temporary access is disabled until the user enters at least 20 non-whitespace characters. When granted, set `temporaryAccessGrantedUntilElapsedMillis = nowElapsed + 5 minutes` on the current BypassAttempt. The blocking engine checks for an unexpired temporary access record for the same targetLabel and attemptType before showing BlockIntercept again. If the user opens protected settings, changes timezone, or requests temporary access more than 3 times within 30 elapsed minutes, Dashboard shows a bypass-pattern warning and the next temporary access grant is disabled until the current schedule or focus session ends.

**Acceptance criteria:**
- The Grant 5 minute access button is disabled for an empty reason.
- The button remains disabled for fewer than 20 non-whitespace characters.
- After a valid reason, the same target can be opened without BlockIntercept for 5 elapsed minutes.
- After the 5-minute elapsed window, opening the same target shows BlockIntercept again.
- After 4 temporary access requests within 30 elapsed minutes, Dashboard shows a bypass-pattern warning and BlockIntercept disables further temporary grants for the active block.

### Active-block notifications

Show a clear notification when a schedule or focus session is active without blocking all phone notifications.

- **Answers complaint:** Account/activation bugs Login and account creation that times out and blocks premium features from ever activating after payment, plus a scheduling bug that blocked all notifications instead of just the targeted apps.

- **Screens:** OnboardingPermissions, Dashboard, ScheduleEditor, FocusSession

- **Estimated hours:** 8

**Implementation notes:** Request POST_NOTIFICATIONS on Android 13+. Create a notification channel `active_blocks` with low importance. WorkManager and Dashboard resume call a notification refresher that posts one persistent notification only when a schedule or focus session is active, with text naming the active block and end time. The app must not request Notification Listener access and must not suppress third-party notifications; notification behavior is limited to the app's own status notification.

**Acceptance criteria:**
- Starting a focus session posts exactly one FocusFort active-block notification if notification permission is granted.
- Ending the focus session cancels the active-block notification.
- Creating a schedule does not request Notification Listener access.
- During an active block, notifications from unrelated apps are not programmatically cancelled or hidden by FocusFort.

## Store listing

- **Title:** FocusFort
- **Short description:** Harder-to-bypass app, site, and keyword blocking.
- **Category:** Productivity
- **Keywords:** screen time blocker, app blocker, focus timer, website blocker, keyword blocker, digital wellbeing, productivity, distraction blocker
- **Icon prompt:** Android app icon, simple flat vector style, deep green rounded-square background #1E5B4F, centered white shield glyph with a subtle check-shaped negative space, no text, no gradients, no shadows, high contrast, suitable for a productivity screen-time blocker.

**Long description:**

FocusFort helps you keep screen-time promises when ordinary blockers are too easy to defeat. Create recurring schedules or instant focus sessions, choose blocked apps, websites, and keywords, and let FocusFort enforce them with targeted Android Usage Access and Accessibility checks.

Built for the gaps users complain about most:
• Timezone-change hardening keeps active blocks from disappearing after a clock change.
• Targeted settings protection blocks only bypass-related settings, not every phone setting.
• Written-reason temporary access adds friction before a short unlock.
• Reliable subscription activation keeps paid access available even if account activation is delayed.
• Capability-based entitlement records are designed so v1 core features are not silently moved behind different gates later.

FocusFort does not sell itself as parental monitoring and does not sync your block lists or browsing activity to the cloud in v1. Your targets, schedules, sessions, and bypass attempts stay on your device.

## Legal

- **Regulated category:** none
- **Privacy policy URL:** https://focusfort.app/privacy (privacy claims verified: no)
- **Data collected:** Firebase anonymous user ID; RevenueCat app user ID; subscription product ID and entitlement status; blocked app package names stored locally on device; website patterns and keywords stored locally on device; schedules and focus session times stored locally on device; bypass attempts and written temporary-access reasons stored locally on device

## Test plan

### 1. timezone-change bypass (unit)

1. Create a BlockSchedule for every day from 09:00 to 17:00.
2. Create ClockGuardState with lastWallClockMillis representing Monday 10:00, lastElapsedRealtimeMillis=1000000, and lastZoneId='America/New_York'.
3. Call the clock guard check with nowElapsed=1060000, nowWall representing Monday 18:00, and currentZoneId='America/Los_Angeles'.
4. Pass previous trusted time as inside an active schedule.

**Expected:** The check inserts a TIMEZONE_CHANGED bypass event and returns a tamperLockUntilElapsedMillis equal to nowElapsed plus 2 hours; the blocking decision remains active.

### 2. protected settings should not block unrelated settings (unit)

1. Set an active focus session from elapsedRealtime=1000 to 3601000.
2. Evaluate foreground settings panel class name 'com.android.settings.DateTimeSettings'.
3. Evaluate foreground settings panel class name 'com.android.settings.wifi.WifiSettings'.

**Expected:** DateTimeSettings returns blocked with attemptType PROTECTED_SETTINGS; WifiSettings returns allowed.

### 3. payment-to-premium activation blocked by login timeout (unit)

1. Mock Firebase anonymous sign-in to time out after 8 seconds.
2. Mock RevenueCat CustomerInfo with entitlement id 'premium' active and product id 'focusfort_monthly_299'.
3. Run the entitlement refresh use case.
4. Read LocalEntitlement from the Room test database.

**Expected:** LocalEntitlement isActive is true, productId is 'focusfort_monthly_299', SubscriptionActivation state is firebase_activation_delayed_but_local_active, and premium navigation is allowed.

### 4. billing moves the goalposts (unit)

1. Create LocalEntitlement with grantedCapabilitiesCsv='APP_BLOCKING,WEBSITE_BLOCKING,KEYWORD_BLOCKING,SCHEDULES,FOCUS_SESSIONS,ALLOW_LIST,HARDENING'.
2. Create a simulated new default capability set missing 'ALLOW_LIST'.
3. Run the entitlement merge function.
4. Query whether the allow-list feature is enabled.

**Expected:** The merged LocalEntitlement still contains ALLOW_LIST and the allow-list feature check returns true.

### 5. baseline app blocking (instrumented)

1. Insert an APP BlockTarget for packageName='com.example.blocked' into the Room database.
2. Insert an active FocusSession ending 30 minutes after current SystemClock.elapsedRealtime.
3. Send a fake Accessibility foreground event to the blocking engine with packageName='com.example.blocked'.
4. Observe the navigation command emitted by the blocking engine.

**Expected:** A BLOCKED_APP BypassAttempt is stored and the navigation command routes to BlockIntercept with that attempt id.

### 6. baseline website and keyword blocking (unit)

1. Insert a WEBSITE BlockTarget with pattern='example.com'.
2. Insert a KEYWORD BlockTarget with pattern='games'.
3. Set a schedule active at the injected current time.
4. Evaluate visible text 'https://www.example.com/news'.
5. Evaluate visible text 'I want to play games now'.
6. Evaluate visible text 'endgames article'.

**Expected:** The first two evaluations are blocked; the third evaluation is allowed because 'games' is not a whole-word match.

### 7. written-reason temporary access (instrumented)

1. Launch BlockIntercept with a stored BLOCKED_APP BypassAttempt.
2. Enter text 'need it' into the reason field.
3. Check the Grant 5 minute access button state.
4. Replace the text with 'I need to reply to a time sensitive work message'.
5. Tap Grant 5 minute access.
6. Read the updated BypassAttempt from Room.

**Expected:** The button is disabled for 'need it', enabled for the longer reason, and the stored attempt has temporaryAccessGrantedUntilElapsedMillis approximately 5 minutes after grant time.

### 8. scheduling bug blocked all notifications instead of just targeted apps (manual)

1. Install the app on an Android 13 or newer test device.
2. Grant POST_NOTIFICATIONS when prompted.
3. Create one blocked app target and start a 15-minute focus session.
4. From another phone or test service, send a notification to an unrelated app such as a messaging app that is not in the blocked target list.
5. Open the notification shade during the active focus session.

**Expected:** The unrelated app notification remains visible; FocusFort shows only its own active-block status notification and does not suppress unrelated notifications.

## Build instructions

```sh
Copy-paste commands from the project root:

./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
RELEASE_STORE_FILE="$PWD/release.keystore" RELEASE_STORE_PASSWORD="$RELEASE_STORE_PASSWORD" RELEASE_KEY_ALIAS="$RELEASE_KEY_ALIAS" RELEASE_KEY_PASSWORD="$RELEASE_KEY_PASSWORD" ./gradlew bundleRelease

The release Gradle signingConfig must read RELEASE_STORE_FILE, RELEASE_STORE_PASSWORD, RELEASE_KEY_ALIAS, and RELEASE_KEY_PASSWORD; the final signed AAB is produced at app/build/outputs/bundle/release/app-release.aab.
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
