# OpenVault Safe — build spec

An encrypted photo and video vault with a written guarantee that non-payment only caps new uploads and never deletes or locks away existing content.

## 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 the optional api_access endpoints), and design working_name/package_id/store_listing/design_system so the result is clearly its own product.

## Incumbent app

- **Name:** Private Photo Vault - Keepsafe
- **Package id:** `com.kii.safe`
- **Google Play:** https://play.google.com/store/apps/details?id=com.kii.safe
- **appy.fyi report:** https://appy.fyi/report/com.kii.safe
- **Category:** Photography

## Overview

- **Working name:** OpenVault Safe (trademark cleared: no)
- **Package id:** `com.appyfyi.openvaultsafe`
- **Min / target SDK:** 26 / 35
- **Backend:** firebase
- **Estimated build time:** 7 weeks
- **Pricing:** subscription, $2.99 via `revenuecat`
- **Runtime AI:** none
- **Permissions:** `USE_BIOMETRIC`, `CAMERA`, `INTERNET`

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

- No third-party advertising network or in-vault promotional scare ads in v1.
- No email, phone number, or social sign-in requirement in v1.
- No AI photo search in v1 because the report calls it a nice-to-have, not the reason to build.
- No full Keepsafe-style social album sharing or collaboration features in v1.
- No manual file-system browsing or MANAGE_EXTERNAL_STORAGE access in v1; imports use the Android system photo picker only.

## Tech stack

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

| Purpose | Gradle coordinate |
| --- | --- |
| Compose activity integration and Activity Result APIs for Photo Picker and MediaStore delete confirmation | `androidx.activity:activity-compose:1.9.3` |
| Compose navigation graph for unlock, vault, detail, import, backup, paywall, settings, and break-in log screens | `androidx.navigation:navigation-compose:2.8.3` |
| Lifecycle-aware ViewModel state collection in Compose screens | `androidx.lifecycle:lifecycle-runtime-compose:2.8.7` |
| Room local database for vault item metadata, backup status, settings flags, and break-in records | `androidx.room:room-runtime:2.6.1` |
| Coroutine support for Room DAO suspend functions and Flow queries | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor for Kotlin via KSP | `androidx.room:room-compiler:2.6.1` |
| EncryptedSharedPreferences for PIN hash, decoy PIN hash, recovery key material, and Keystore alias metadata | `androidx.security:security-crypto:1.1.0-alpha06` |
| BiometricPrompt integration for fingerprint and face unlock after PIN setup | `androidx.biometric:biometric:1.1.0` |
| Image thumbnail and detail rendering from decrypted temporary files | `io.coil-kt:coil-compose:2.7.0` |
| Firebase anonymous authentication for cloud backup without email or phone sign-up | `com.google.firebase:firebase-auth:23.1.0` |
| Firestore manifest documents for backup status, backup item index, and last-successful-backup timestamp | `com.google.firebase:firebase-firestore:25.1.1` |
| Firebase Storage upload and download of already-encrypted vault blobs | `com.google.firebase:firebase-storage:21.0.1` |
| Await Firebase Task results from coroutines | `org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0` |
| RevenueCat subscription entitlement checks for the $2.99/month unlimited tier | `com.revenuecat.purchases:purchases:8.10.6` |
| CameraX core camera pipeline for front-camera break-in alert capture after failed unlock attempts | `androidx.camera:camera-core:1.4.0` |
| Camera2 implementation backend for CameraX break-in alert capture | `androidx.camera:camera-camera2:1.4.0` |
| Lifecycle binding for CameraX break-in alert capture | `androidx.camera:camera-lifecycle:1.4.0` |

## Design system

- **Primary color:** `#0F766E`
- **Background color:** `#071311`
- **Error color:** `#DC2626`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `lock` (regular weight)
- **Theme notes:** Use a dark-first trust/security theme. Dark mode background is #071311 with #0F766E primary buttons and white text at 87% opacity. Light mode background is #F7FAF9 with the same #0F766E primary and #111827 text. Destructive actions use #DC2626. Never use red warning styling for payment prompts; payment prompts must use neutral text and the primary color so they do not resemble scare ads.

## Screens

### Unlock
- **Route:** `unlock`
- **Purpose:** Gate all vault content behind PIN, biometric unlock, or decoy PIN.
- **Reached via:** app launch; return to app after background timeout; tap Lock now in Settings
- **Key UI elements:** App name and lock glyph; Six-digit PIN keypad; Biometric unlock button shown only after real PIN has been created and biometric is enabled; Forgot recovery code help link; Inline error text after failed unlock
- **States:** first_run_create_pin, confirm_pin, locked, biometric_available, biometric_unavailable, wrong_pin, camera_permission_needed_for_break_in_alerts, decoy_unlocked, real_unlocked

### Vault
- **Route:** `vault`
- **Purpose:** Show the real encrypted photo and video library and the current free-tier or paid storage status.
- **Reached via:** successful real PIN unlock; successful biometric unlock; back from Item Detail; back from Import Review; back from Paywall
- **Key UI elements:** Top app bar with title OpenVault Safe; Backup status chip showing last successful backup or backup off; Storage tier chip showing Free 0/200, Free limit reached, or Unlimited; Two-column media thumbnail grid; Import floating action button; Settings button; Empty-state explanation that stored items are never deleted for non-payment
- **States:** loading, empty, populated, free_limit_reached, paid_unlimited, backup_error_banner, decrypt_thumbnail_error

### Decoy Vault
- **Route:** `decoyVault`
- **Purpose:** Show a harmless empty vault when the decoy PIN is entered.
- **Reached via:** enter decoy PIN on Unlock
- **Key UI elements:** Top app bar with title Vault; Empty-state illustration; Text saying no private items have been added; Import button disabled with neutral explanation
- **States:** empty_decoy, locked

### Import Review
- **Route:** `import`
- **Purpose:** Encrypt selected photos and videos into app-private storage and optionally ask Android to remove originals from the public gallery.
- **Reached via:** tap Import floating action button on Vault; tap Import in empty Vault
- **Key UI elements:** List of selected media filenames or fallback URI labels; Per-item import progress rows; Import into vault button; Checkbox labeled Ask Android to delete originals after safe import; Result summary with imported count, failed count, and originals deletion result
- **States:** waiting_for_picker, selection_empty, selection_populated, encrypting, partial_failure, import_complete, delete_originals_pending_system_confirmation, delete_originals_denied, free_limit_reached

### Item Detail
- **Route:** `item/{itemId}`
- **Purpose:** Display one decrypted photo or playable video from the vault with delete and backup-state information.
- **Reached via:** tap a media tile on Vault
- **Key UI elements:** Full-screen media viewer; Video play/pause controls for video items; Created/imported timestamp; Backup state label: not backed up, backed up, or backup failed; Delete from vault button with confirmation dialog; Close button
- **States:** loading, photo_loaded, video_loaded, decrypt_error, missing_file_error, delete_confirming, deleted

### Backup Status
- **Route:** `backup`
- **Purpose:** Show encrypted cloud backup state, last successful backup time, recovery code status, and restore controls.
- **Reached via:** tap backup status chip on Vault; tap Backup in Settings; after subscription purchase success
- **Key UI elements:** Last successful backup timestamp; Backup enabled switch; Generate or view recovery code button; Manual backup now button; Restore from recovery code button; Per-item backup queue summary; Error card with retry button
- **States:** loading, backup_off, no_recovery_code, ready, syncing, last_backup_successful, backup_failed, restore_code_entry, restoring, restore_success, restore_failed

### Paywall
- **Route:** `paywall`
- **Purpose:** Sell the ad-free unlimited subscription without threatening existing content.
- **Reached via:** tap Free limit reached chip on Vault; tap Upgrade from Settings; attempt to import item 201 on free tier; tap Upgrade from Backup Status when cloud backup is locked
- **Key UI elements:** Headline: Unlimited imports and encrypted cloud backup; Guarantee card: Existing vault items are never deleted for non-payment; $2.99/month subscription option; Subscribe button; Restore purchases button; Continue with free 200-item vault button; No ads statement
- **States:** loading_products, products_loaded, purchase_in_progress, purchase_success, purchase_cancelled, purchase_error, already_subscribed

### Settings
- **Route:** `settings`
- **Purpose:** Configure lock behavior, biometric unlock, decoy PIN, break-in alerts, backup, subscription status, and privacy/legal links.
- **Reached via:** tap Settings button on Vault
- **Key UI elements:** Biometric unlock toggle; Change real PIN row; Set or change decoy PIN row; Break-in alerts toggle; Backup row with last successful backup; Subscription row; Privacy policy link; Lock now button
- **States:** loading, populated_free, populated_paid, biometric_not_enrolled, camera_permission_missing, settings_save_error

### Break-In Log
- **Route:** `breakIns`
- **Purpose:** Show local records of failed unlock attempts and front-camera alert photos captured after repeated failures.
- **Reached via:** tap Break-in alerts row in Settings
- **Key UI elements:** Chronological failed-attempt list; Thumbnail of captured alert photo when available; Reason label: wrong PIN threshold reached; Delete log entry button; Empty state
- **States:** loading, empty, populated, camera_capture_unavailable, delete_confirming

## Data model

### VaultItem (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; UUID generated before encryption |
| mediaType | `String` | PHOTO or VIDEO |
| encryptedFilePath | `String` | absolute path under app-private files/vault/ |
| thumbnailFilePath | `String?` | nullable path under app-private cache/thumbs/ containing a decrypted thumbnail regenerated as needed |
| originalDisplayName | `String?` | nullable filename from ContentResolver OpenableColumns.DISPLAY_NAME |
| mimeType | `String` | for example image/jpeg or video/mp4 |
| byteSize | `Long` | size of encrypted file in bytes |
| importedAtEpochMillis | `Long` | System.currentTimeMillis at successful encrypted copy |
| sha256Ciphertext | `String` | hex SHA-256 of encrypted file bytes used to skip duplicate backup uploads |
| backupState | `String` | NOT_BACKED_UP, QUEUED, BACKED_UP, or FAILED |
| lastBackedUpAtEpochMillis | `Long?` | nullable |
| cloudStoragePath | `String?` | nullable Firebase Storage path backups/{backupId}/items/{id}.ovs |

### BackupState (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Int` | primary key; always 1 |
| backupEnabled | `Boolean` | false until user enables encrypted backup |
| backupId | `String?` | nullable; random 192-bit URL-safe Base64 id included in recovery code |
| lastSuccessfulBackupAtEpochMillis | `Long?` | nullable; displayed verbatim on Vault and Backup Status |
| lastErrorMessage | `String?` | nullable; user-visible backup failure summary |
| restoreInProgress | `Boolean` |  |

### AppSettings (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Int` | primary key; always 1 |
| biometricEnabled | `Boolean` |  |
| breakInAlertsEnabled | `Boolean` |  |
| failedUnlockCount | `Int` | reset to 0 after successful real unlock |
| subscriptionEntitlement | `String` | FREE or UNLIMITED |
| lastEntitlementCheckAtEpochMillis | `Long?` | nullable |

### BreakInAttempt (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; UUID |
| attemptedAtEpochMillis | `Long` |  |
| capturedPhotoPath | `String?` | nullable app-private encrypted alert image path |
| reason | `String` | WRONG_PIN_THRESHOLD |

### SecureVaultSecrets (`encrypted_local`)

| Field | Type | Notes |
| --- | --- | --- |
| pinSaltBase64 | `String` | 16 random bytes Base64 |
| pinHashBase64 | `String` | PBKDF2WithHmacSHA256 hash of real PIN, 310000 iterations, 256-bit output |
| decoyPinSaltBase64 | `String?` | nullable; 16 random bytes Base64 |
| decoyPinHashBase64 | `String?` | nullable; PBKDF2WithHmacSHA256 hash of decoy PIN |
| vaultAesKeyBase64 | `String` | 256-bit AES key encrypted by EncryptedSharedPreferences master key; also shown as part of recovery code only when user requests it |
| recoveryCodeAcknowledged | `Boolean` | true only after user confirms they saved the recovery code |

### BackupManifest (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| backupId | `String` | document id under collection backups |
| schemaVersion | `Int` | 1 |
| lastSuccessfulBackupAtEpochMillis | `Long` |  |
| itemCount | `Int` |  |
| updatedByAnonymousUid | `String` | Firebase anonymous auth uid; not email or phone |

### BackupItem (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | document id matching VaultItem.id under backups/{backupId}/items |
| mediaType | `String` | PHOTO or VIDEO |
| mimeType | `String` |  |
| byteSize | `Long` | encrypted byte size |
| sha256Ciphertext | `String` |  |
| cloudStoragePath | `String` | Firebase Storage path |
| uploadedAtEpochMillis | `Long` |  |

## Features

### PIN and biometric vault lock

Protect all real vault content behind a six-digit PIN with optional biometric unlock.

- **Answers complaint:** baseline parity

- **Screens:** Unlock, Vault, Settings

- **Estimated hours:** 32

**Implementation notes:** On first launch route to Unlock in first_run_create_pin state. Require exactly six numeric digits, ask for confirmation, generate 16 random salt bytes with SecureRandom, and store PBKDF2WithHmacSHA256(pin, salt, 310000 iterations, 256-bit output) in EncryptedSharedPreferences. On later launches hash entered PIN with the stored salt and compare with MessageDigest.isEqual. Enable biometric only after a real PIN exists and BiometricManager.canAuthenticate(BIOMETRIC_STRONG) succeeds; use BiometricPrompt from androidx.biometric and route to Vault only on AuthenticationResult success. Lock the app whenever ProcessLifecycleOwner reports ON_STOP for more than 30 seconds; implement this with a timestamp in memory and a single NavController navigation to unlock with popUpTo root.

**Acceptance criteria:**
- A new install cannot reach Vault until a confirmed six-digit PIN is created.
- Entering an incorrect PIN keeps the user on Unlock and increments failedUnlockCount by 1.
- Entering the correct PIN navigates to Vault and resets failedUnlockCount to 0.
- Biometric unlock button is hidden when no biometric is enrolled and visible when BIOMETRIC_STRONG is available and enabled.
- After the app is backgrounded for at least 31 seconds, reopening it shows Unlock instead of Vault.

### Vault browsing and media viewer

Show encrypted photos and videos as a private grid and allow one item to be opened in a detail viewer.

- **Answers complaint:** baseline parity

- **Screens:** Vault, Item Detail

- **Estimated hours:** 18

**Implementation notes:** Read VaultItem rows from Room as Flow<List<VaultItem>> ordered by importedAtEpochMillis descending. For thumbnails, decrypt each item into a cache file only when a tile becomes visible; for photos decode a sampled bitmap with BitmapFactory.Options.inSampleSize, and for videos create a thumbnail with MediaMetadataRetriever.getFrameAtTime(0). Store regenerated thumbnail cache paths in VaultItem.thumbnailFilePath. Use Coil AsyncImage for thumbnail and photo detail rendering from decrypted cache files. For video detail, decrypt the encrypted blob to a temporary file in cache/detail/{itemId}.mp4 and play it with Android MediaPlayer via a Compose AndroidView SurfaceView; delete decrypted detail cache files in DisposableEffect on screen exit.

**Acceptance criteria:**
- An empty database shows the Vault empty state with the never-delete guarantee text.
- A database with three VaultItem rows shows three grid tiles in newest-first order.
- Tapping a photo tile opens Item Detail and displays the decrypted image.
- Tapping a video tile opens Item Detail and the play button starts playback from a decrypted temporary file.
- Leaving Item Detail deletes the decrypted temporary detail file from cache/detail.

### Gallery import with local AES encryption

Import selected photos and videos into app-private encrypted storage using the Android photo picker.

- **Answers complaint:** baseline parity

- **Screens:** Import Review, Vault

- **Estimated hours:** 60

**Implementation notes:** Launch ActivityResultContracts.PickMultipleVisualMedia with PickVisualMedia.ImageAndVideo from Import Review. For each returned Uri, open an InputStream with ContentResolver.openInputStream, derive display name and MIME type from ContentResolver, and write to files/vault/{uuid}.ovs with format: ASCII bytes 'OVS1', one version byte 1, 12 random IV bytes, then AES/GCM/NoPadding ciphertext and 16-byte tag produced with a 256-bit vaultAesKeyBase64 stored in EncryptedSharedPreferences. Compute SHA-256 over the complete encrypted .ovs file after write. Insert a VaultItem row only after fsync and successful close. If the user checks Ask Android to delete originals, call MediaStore.createDeleteRequest(contentResolver, selectedUris) on API 30+ after all selected files are safely encrypted; if the user denies the system confirmation, keep the encrypted vault copies and show delete_originals_denied instead of rolling back.

**Acceptance criteria:**
- Selecting no media returns to Import Review selection_empty state without creating VaultItem rows.
- Importing two selected images creates two .ovs files under app-private files/vault and two VaultItem rows.
- A VaultItem row is not created if encryption or file write fails for that item.
- The encrypted .ovs file does not contain the plaintext JPEG or PNG magic bytes at offset 0.
- If Android original deletion is denied, the imported encrypted copies remain visible in Vault.

### Never-delete free tier and subscription cap

Let free users keep up to 200 imported items forever and cap only new imports when they are not unlimited subscribers.

- **Answers complaint:** Pay-or-lose-your-photos

- **Screens:** Vault, Import Review, Paywall, Settings

- **Estimated hours:** 24

**Implementation notes:** Use AppSettings.subscriptionEntitlement, refreshed from RevenueCat CustomerInfo entitlement id 'unlimited', to decide whether the account is FREE or UNLIMITED. Before starting each import, count existing VaultItem rows. If entitlement is FREE and count is already 200, do not open the picker; navigate to Paywall with source=free_limit. If a batch import would cross 200, import only the first slots up to 200 and mark the remaining selected items as skipped_free_limit without deleting any existing VaultItem or encrypted file. On entitlement loss or purchase expiration, leave every VaultItem row and encrypted file untouched, keep viewing and export-to-cache available, disable only new imports past 200 and new cloud uploads. The Paywall copy must state: 'Existing vault items are never deleted for non-payment.'

**Acceptance criteria:**
- With 200 existing free-tier VaultItem rows, tapping Import opens Paywall and creates no deletion jobs.
- When a paid entitlement is changed to FREE with 250 existing VaultItem rows, all 250 remain visible and openable in Vault.
- A free user with 199 items selecting 3 new items imports exactly 1 item and marks 2 as skipped because of the free limit.
- No code path called during entitlement refresh deletes files under files/vault.
- The Paywall contains the exact visible guarantee text 'Existing vault items are never deleted for non-payment.'

### Verifiable encrypted cloud backup and restore

Back up already-encrypted vault files to Firebase with a visible last-successful-backup timestamp and recovery-code restore.

- **Answers complaint:** Backup doesn't work

- **Screens:** Backup Status, Vault, Settings, Paywall

- **Estimated hours:** 98

**Implementation notes:** When backup is enabled, require the user to view and acknowledge a recovery code composed of backupId + vaultAesKeyBase64, formatted as OVS1-{backupId}-{base64urlKey}. Generate backupId as 24 SecureRandom bytes encoded Base64 URL without padding. Sign into Firebase Auth anonymously, create Firestore backups/{backupId} manifest, and upload each existing encrypted .ovs file unchanged to Firebase Storage path backups/{backupId}/items/{itemId}.ovs. After Storage upload succeeds, write Firestore backups/{backupId}/items/{itemId} with sha256Ciphertext, byteSize, mimeType, mediaType, cloudStoragePath, and uploadedAtEpochMillis. Only after all queued items succeed, update BackupState.lastSuccessfulBackupAtEpochMillis and Firestore manifest.lastSuccessfulBackupAtEpochMillis to System.currentTimeMillis. Display that exact timestamp on Vault and Backup Status. Restore asks for OVS1 recovery code, parses backupId and key, downloads Firestore item documents and Storage blobs, verifies SHA-256 of each downloaded encrypted blob matches the Firestore sha256Ciphertext, writes files/vault/{id}.ovs, inserts VaultItem rows, and sets backup state to BACKED_UP. If any item hash check fails, skip that item and show restore_failed with the item id.

**Acceptance criteria:**
- Backup Status shows 'No successful backup yet' until a full upload batch has completed.
- After a successful backup of two items, Vault backup chip displays a non-null last successful backup timestamp.
- Deleting the app data, reinstalling, entering the recovery code, and restoring recreates the same two VaultItem rows and encrypted files.
- If a downloaded blob SHA-256 differs from Firestore sha256Ciphertext, that item is not inserted into Room and restore_failed is shown.
- A free user without unlimited entitlement can view existing backup status and restore, but cannot start new cloud backup uploads past the free limits.

### No email account requirement

Allow local vault use and encrypted backup setup without asking for email, phone number, or social login.

- **Answers complaint:** Invasive account requirement

- **Screens:** Backup Status, Settings

- **Estimated hours:** 12

**Implementation notes:** Do not include any sign-in screen. For cloud backup, call FirebaseAuth.signInAnonymously automatically when the user enables backup or restore. Store only the resulting anonymous uid in Firebase service state and BackupManifest.updatedByAnonymousUid; never show email, phone, Google, Facebook, or password fields. Cross-device restore is handled solely by the OVS1 recovery code containing backupId and vault key. If anonymous sign-in fails because the network is unavailable, show backup_failed with a retry button and keep local vault access fully enabled.

**Acceptance criteria:**
- A fresh install can create a PIN, import one photo, and view it without any email or account prompt.
- Enabling backup triggers Firebase anonymous auth without rendering a sign-in form.
- The app contains no UI text fields labeled Email, Phone, Password, Google, Facebook, or Sign in.
- When Firebase anonymous sign-in fails, existing local vault items remain accessible.

### Break-in alerts and decoy PIN

Capture a local alert record after repeated wrong PIN attempts and support a decoy PIN that opens an empty harmless vault.

- **Answers complaint:** baseline parity

- **Screens:** Unlock, Settings, Break-In Log, Decoy Vault

- **Estimated hours:** 28

**Implementation notes:** Let the user enable break-in alerts in Settings. Request CAMERA permission only when enabling this toggle. On every wrong real-PIN attempt, increment AppSettings.failedUnlockCount. When the count reaches 3 and break-in alerts are enabled, bind CameraX to the front camera with CameraSelector.DEFAULT_FRONT_CAMERA, capture one JPEG with ImageCapture.takePicture into cache/breakins/{uuid}.jpg, encrypt that JPEG with the same OVS1 AES-GCM file format into files/breakins/{uuid}.ovs, delete the plaintext cache JPEG, and insert BreakInAttempt. If camera capture fails, insert BreakInAttempt with capturedPhotoPath null and reason WRONG_PIN_THRESHOLD. For decoy PIN, store a separate PBKDF2 hash and salt in EncryptedSharedPreferences; if entered PIN matches decoy hash, navigate to Decoy Vault and never query real VaultItem rows in that navigation branch.

**Acceptance criteria:**
- After three wrong PIN entries with break-in alerts enabled and camera permission granted, a BreakInAttempt row is created.
- The plaintext cache JPEG created for a break-in capture is deleted after encryption.
- If camera permission is denied, wrong PIN attempts do not crash and a camera_permission_missing state is shown in Settings.
- Entering the decoy PIN opens Decoy Vault and displays zero real VaultItem thumbnails.
- Entering the real PIN after a decoy session opens the real Vault with its actual item count.

### Ad-free trust boundary

Keep the vault free of third-party ads and scare-tactic security popups.

- **Answers complaint:** Deceptive ads inside a vault

- **Screens:** Vault, Paywall

- **Estimated hours:** 8

**Implementation notes:** Do not add any ad SDK dependency, WebView ad container, remote marketing message endpoint, interstitial screen, or notification promotion. Monetization is only RevenueCat purchase flow from Paywall. Implement an internal static copy rule for payment prompts: Paywall and free-limit messages may mention storage limits and subscription benefits, but must not contain the words 'virus', 'infected', 'threat found', 'cleaner', or 'scan result'. Add a unit test that inspects all Compose-visible string resources for those banned words and a Gradle dependency check task that fails if dependency coordinates contain 'play-services-ads', 'applovin', 'ironsource', 'unity-ads', or 'facebook-ads'.

**Acceptance criteria:**
- The dependency tree contains no Google Mobile Ads, AppLovin, ironSource, Unity Ads, or Facebook Audience Network artifact.
- No screen in the app displays the words virus, infected, threat found, cleaner, or scan result.
- The only monetization entry point is Paywall using RevenueCat.
- Vault remains usable with network disabled and does not show ad placeholders.

## Store listing

- **Title:** OpenVault Safe
- **Short description:** Encrypted photo vault that never deletes your saved items for non-payment.
- **Category:** Photography
- **Keywords:** photo vault, video vault, encrypted gallery, private photos, biometric lock, cloud backup, decoy pin, no ads
- **Icon prompt:** Create a modern Android app icon for an encrypted private photo vault named OpenVault Safe: dark teal rounded-square background (#071311), centered simple line-art lock glyph in bright teal (#0F766E) with a subtle photo-frame outline behind it, flat vector style, high contrast, no text, no gradients, no brand logos, suitable for Play Store adaptive icon foreground.

**Long description:**

OpenVault Safe is an honest encrypted photo and video vault built around one promise: existing vault items are never deleted or held hostage because you did not pay.

Start free with a generous 200-item vault. If you reach the free limit, new imports stop until you upgrade, but everything already inside stays viewable and protected. Upgrade for unlimited imports and encrypted cloud backup.

What v1 includes:
• Six-digit PIN and optional biometric unlock
• Local AES-GCM encryption for imported photos and videos
• Android system photo picker import
• Optional deletion request for originals after safe import
• Visible encrypted backup status with last successful backup time
• Recovery-code restore after reinstall or phone change
• No email or phone sign-up requirement
• Break-in alerts after repeated wrong PIN attempts
• Decoy PIN for a harmless empty vault
• No third-party ads inside your private vault

Your cloud backup files are encrypted before upload. OpenVault Safe uses a recovery code instead of an invasive email account so you can restore your encrypted library without exposing more personal information than necessary.

## Legal

- **Regulated category:** none
- **Privacy policy URL:** https://appyfyi.com/privacy/openvaultsafe (privacy claims verified: no)
- **Data collected:** Firebase anonymous user identifier for backup; RevenueCat subscription entitlement and purchase status; Encrypted photo and video backup blobs uploaded only when backup is enabled; Encrypted backup metadata including item ids, encrypted byte sizes, MIME types, ciphertext hashes, and last successful backup timestamp

## Test plan

### 1. Pay-or-lose-your-photos (instrumented)

1. Install a debug build with a fake RevenueCat entitlement provider returning UNLIMITED.
2. Create PIN 123456 on Unlock.
3. Seed 250 encrypted VaultItem rows and matching .ovs files using the app test fixture.
4. Open Vault and assert 250 tiles are visible through the lazy grid semantics count helper.
5. Change fake entitlement provider to FREE and trigger Settings subscription refresh.
6. Return to Vault.
7. Open item 249 from the grid.

**Expected:** All 250 items remain in Room and files/vault, Vault still shows 250 items, and item 249 opens successfully instead of being deleted or locked behind Paywall.

### 2. Pay-or-lose-your-photos (instrumented)

1. Install a debug build with fake entitlement FREE.
2. Create PIN 123456.
3. Seed exactly 200 VaultItem rows and matching encrypted files.
4. Open Vault.
5. Tap the Import floating action button.

**Expected:** The app navigates to Paywall, no VaultItem rows are deleted, no files under files/vault are deleted, and Paywall displays 'Existing vault items are never deleted for non-payment.'

### 3. Backup doesn't work (manual)

1. Install the app on a test device with network enabled.
2. Create PIN 123456.
3. Import two known test images through the Android photo picker.
4. Open Backup Status, enable backup, copy the displayed OVS1 recovery code, and tap Manual backup now.
5. Wait until Backup Status shows a concrete last successful backup timestamp.
6. Clear app storage from Android Settings to simulate reinstall data loss.
7. Relaunch the app, create a new PIN, open Backup Status, choose Restore from recovery code, and paste the copied OVS1 recovery code.

**Expected:** Restore finishes with restore_success, Vault shows the same two imported images, and Backup Status shows a non-null last successful backup timestamp.

### 4. Backup doesn't work (unit)

1. Create a fake Firestore BackupItem with sha256Ciphertext set to the SHA-256 of blob A.
2. Configure fake Firebase Storage download to return different blob B for the same item id.
3. Run the restore use case with that fake backup id and recovery key.
4. Query the in-memory Room database for the item id.

**Expected:** The item with the mismatched hash is not inserted into Room and the restore result contains restore_failed with the mismatched item id.

### 5. Invasive account requirement (instrumented)

1. Install a debug build with FirebaseAuth fake configured to succeed anonymously.
2. Create PIN 123456.
3. Import one image through the photo picker.
4. Open Backup Status and enable backup.
5. Inspect all displayed text nodes on Unlock, Vault, Import Review, Backup Status, and Settings.

**Expected:** No visible text contains Email, Phone, Password, Google, Facebook, or Sign in, and backup setup succeeds using an anonymous uid.

### 6. Deceptive ads inside a vault (unit)

1. Load every string resource from src/main/res/values/strings.xml.
2. Normalize each string to lowercase.
3. Search for banned phrases: virus, infected, threat found, cleaner, scan result.

**Expected:** No production string resource contains any banned scare-ad phrase.

### 7. Deceptive ads inside a vault (manual)

1. Install a release candidate build.
2. Put the device in airplane mode.
3. Create PIN 123456.
4. Import one image.
5. Navigate through Vault, Item Detail, Settings, Backup Status, and Paywall.

**Expected:** No banner ad, interstitial ad, WebView ad, virus warning, or ad placeholder appears on any screen; Vault and Item Detail remain usable offline.

### 8. baseline parity (instrumented)

1. Install the app fresh.
2. Create PIN 123456.
3. Enable biometric unlock only if the test device reports BIOMETRIC_STRONG available.
4. Press Home and wait 31 seconds.
5. Reopen the app.

**Expected:** Unlock is shown before any vault content, and either the biometric button is available on biometric-capable devices or hidden on devices without enrolled biometrics.

### 9. baseline parity (instrumented)

1. Create PIN 123456.
2. Use the Android photo picker test contract to return one JPEG Uri from test assets.
3. Run Import into vault with Ask Android to delete originals unchecked.
4. Open Vault and tap the imported tile.

**Expected:** One VaultItem row exists, one .ovs file exists under files/vault, the grid shows one thumbnail, and Item Detail displays the decrypted JPEG.

### 10. baseline parity (instrumented)

1. Create real PIN 123456.
2. Open Settings and set decoy PIN 000000.
3. Lock the app.
4. Enter 000000 on Unlock.
5. Record the displayed Decoy Vault item count.
6. Lock the app again.
7. Enter 123456 on Unlock.

**Expected:** The decoy PIN opens Decoy Vault with zero real items queried, and the real PIN opens Vault with the actual Room VaultItem count.

### 11. baseline parity (manual)

1. Create PIN 123456.
2. Open Settings, enable Break-in alerts, and grant camera permission.
3. Lock the app.
4. Enter wrong PIN 111111 three times.
5. Unlock with 123456.
6. Open Settings, then Break-In Log.

**Expected:** Break-In Log contains a new WRONG_PIN_THRESHOLD entry; if the camera capture succeeded it has an encrypted captured photo, and no plaintext break-in JPEG remains in app cache.

## Build instructions

```sh
export ANDROID_HOME=${ANDROID_HOME:-$HOME/Android/Sdk}
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
printf '%s' "$PLAY_UPLOAD_KEYSTORE_BASE64" | base64 --decode > release-keystore.jks
export OPENVAULT_KEYSTORE_FILE=$PWD/release-keystore.jks
export OPENVAULT_KEYSTORE_PASSWORD="$PLAY_UPLOAD_KEYSTORE_PASSWORD"
export OPENVAULT_KEY_ALIAS="$PLAY_UPLOAD_KEY_ALIAS"
export OPENVAULT_KEY_PASSWORD="$PLAY_UPLOAD_KEY_PASSWORD"
./gradlew bundleRelease
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
