# CanvasOnce Studio — build spec

A mobile drawing app that sells one clear $12.99 unlock with layers, folders, large custom canvases, export, and real cloud backup included instead of a second subscription paywall.

## 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:** ibis Paint
- **Package id:** `jp.ne.ibis.ibispaint.app`
- **Google Play:** https://play.google.com/store/apps/details?id=jp.ne.ibis.ibispaint.app
- **appy.fyi report:** https://appy.fyi/report/jp.ne.ibis.ibispaint.app
- **Category:** Art & Design

## Overview

- **Working name:** CanvasOnce Studio (trademark cleared: no)
- **Package id:** `fyi.appy.canvasoncestudio`
- **Min / target SDK:** 26 / 35
- **Backend:** firebase
- **Estimated build time:** 12 weeks
- **Pricing:** one-time purchase, $12.99 via `play_billing_direct`
- **Runtime AI:** none
- **Permissions:** `INTERNET`

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

- No vector drawing tools in v1; the v1 scope is raster canvas, brushes, layers, folders, export, and backup.
- No animation workspace in v1; this avoids the incumbent reliability complaint area while the raster editor is stabilized.
- No manga panel/comic workflow templates in v1.
- No marketplace or downloadable brush catalogue in v1; ship a fixed built-in set of 40 brushes so purchases cannot lose access to a remote brush catalogue.
- No subscription, consumable credits, ad-removal tier, or separate premium membership in v1.
- No runtime AI cleanup or AI color-fill in v1; the report identifies this as a possible narrow differentiator, but the v1 build is scoped to the 12-week drawing, layers, export, cloud, gallery, folders, and billing workstreams.

## Tech stack

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

| Purpose | Gradle coordinate |
| --- | --- |
| Android Gradle Plugin for building the app module | `com.android.tools.build:gradle:8.7.2` |
| KSP annotation processing for Room | `com.google.devtools.ksp:symbol-processing-api:2.0.21-1.0.28` |
| Core Android Kotlin extensions | `androidx.core:core-ktx:1.15.0` |
| Compose activity host | `androidx.activity:activity-compose:1.9.3` |
| Material 3 Compose UI components | `androidx.compose.material3:material3:1.3.0` |
| Compose UI runtime and drawing primitives | `androidx.compose.ui:ui:1.7.4` |
| Compose foundation gestures and canvas support | `androidx.compose.foundation:foundation:1.7.4` |
| Compose Navigation graph | `androidx.navigation:navigation-compose:2.8.3` |
| ViewModel integration with Compose | `androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6` |
| Lifecycle-aware Flow collection in Compose | `androidx.lifecycle:lifecycle-runtime-compose:2.8.6` |
| Local database for artwork metadata, folders, layers, and entitlement cache | `androidx.room:room-runtime:2.6.1` |
| Kotlin coroutine extensions for Room | `androidx.room:room-ktx:2.6.1` |
| Room compiler for KSP | `androidx.room:room-compiler:2.6.1` |
| Persistent settings for editor preferences and last opened artwork | `androidx.datastore:datastore-preferences:1.1.1` |
| Image loading for gallery thumbnails from local files | `io.coil-kt:coil-compose:2.7.0` |
| Google Play Billing one-time unlock purchase | `com.android.billingclient:billing-ktx:7.1.1` |
| Firebase Authentication for cloud backup account identity | `com.google.firebase:firebase-auth-ktx:23.1.0` |
| Firestore metadata for cloud backup manifests and quota records | `com.google.firebase:firebase-firestore-ktx:25.1.1` |
| Firebase Storage for artwork document and preview image backups | `com.google.firebase:firebase-storage-ktx:21.0.1` |
| Coroutines on Android dispatchers for brush rendering, file IO, billing, and Firebase calls | `org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0` |
| Kotlin serialization for artwork document manifests | `org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3` |

## Design system

- **Primary color:** `#2563EB`
- **Background color:** `#FAFAF7`
- **Error color:** `#B3261E`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `paint-brush` (regular weight)
- **Theme notes:** Use Material 3 light and dark themes. Light theme background is #FAFAF7 with #111827 text; dark theme background is #111827 with #F9FAFB text. Primary action buttons use #2563EB in both themes. Canvas surface uses pure #FFFFFF by default and #1F2937 only when the user chooses a dark canvas preset. Do not use gradients or skeuomorphic brush textures in the app chrome; keep UI neutral so artwork remains the focus.

## Screens

### Unlock
- **Route:** `unlock`
- **Purpose:** Explains the single one-time purchase and unlocks the complete app with no subscription tier.
- **Reached via:** app launch when Entitlement.isUnlocked is false; tap Restore purchase from Settings section on Gallery
- **Key UI elements:** Headline: One price. No second paywall.; $12.99 one-time purchase card; Included checklist: brushes, layers, folders, custom canvases, export, cloud backup; Buy once button; Restore purchase button; Billing error message area
- **States:** loading_billing_product, not_purchased, purchase_in_progress, purchased, billing_unavailable, restore_failed

### Gallery
- **Route:** `gallery`
- **Purpose:** Shows local artwork, folders, cloud backup status, and entry points for creating or opening drawings.
- **Reached via:** app launch when Entitlement.isUnlocked is true; back from Editor; back from New Canvas; back from Cloud Backup
- **Key UI elements:** Top app bar with app title; New canvas floating action button; Folder chips row; Artwork thumbnail grid; Cloud status chip showing signed out, syncing, backed up, or error; Overflow menu with Cloud Backup and Restore purchase
- **States:** loading, empty_no_artwork, populated, syncing, cloud_error, local_database_error

### New Canvas
- **Route:** `new-canvas`
- **Purpose:** Creates a blank artwork with preset or custom dimensions.
- **Reached via:** tap New canvas floating action button on Gallery
- **Key UI elements:** Preset size list: Square 2048, Portrait 1668x2388, Landscape 2388x1668, 4K 3840x2160; Custom width numeric field; Custom height numeric field; DPI numeric field defaulting to 300; Folder picker; Create canvas button; Validation text for invalid or excessive dimensions
- **States:** ready, invalid_dimensions, creating, create_failed

### Editor
- **Route:** `editor/{artworkId}`
- **Purpose:** Main raster drawing workspace with brush strokes, eraser, layers, blending, autosave, and export entry.
- **Reached via:** tap artwork thumbnail on Gallery; after successful canvas creation on New Canvas
- **Key UI elements:** Zoomable and pannable drawing canvas; Brush size slider; Opacity slider; Color picker swatch; Brush picker button; Layer panel button; Undo button; Redo button; Export button; Autosave status text
- **States:** loading_artwork, ready, saving, autosave_failed, recoverable_unsaved_snapshot_found, artwork_not_found, render_error

### Brush Library
- **Route:** `brushes`
- **Purpose:** Lets the user choose from the fixed built-in brush set included in the one-time purchase.
- **Reached via:** tap Brush picker button on Editor
- **Key UI elements:** Search field; Brush category tabs: Ink, Pencil, Paint, Marker, Texture; Brush preview stroke list; Selected brush checkmark; Back button
- **States:** loading, populated, empty_search_results, brush_catalogue_load_error

### Layers
- **Route:** `editor/{artworkId}/layers`
- **Purpose:** Manages raster layers, visibility, opacity, order, and blending modes.
- **Reached via:** tap Layer panel button on Editor
- **Key UI elements:** Layer stack list with thumbnails; Add layer button; Delete layer button; Duplicate layer button; Visibility toggle; Opacity slider; Blend mode dropdown: Normal, Multiply, Screen, Overlay; Drag handle for reorder
- **States:** loading, populated, single_layer_minimum, layer_limit_reached, save_failed

### Export
- **Route:** `export/{artworkId}`
- **Purpose:** Exports the current artwork to PNG or JPEG after flattening visible layers.
- **Reached via:** tap Export button on Editor
- **Key UI elements:** Format selector: PNG, JPEG; JPEG quality slider; Transparent background toggle for PNG; Preview thumbnail; Export button; Share button after export; Export error message area
- **States:** loading_artwork, ready, exporting, export_complete, export_failed, artwork_not_found

### Cloud Backup
- **Route:** `cloud-backup`
- **Purpose:** Signs the user in, shows fixed included cloud storage, and backs up or restores artwork without a subscription.
- **Reached via:** tap Cloud status chip on Gallery; tap Cloud Backup from Gallery overflow menu
- **Key UI elements:** Email field; Password field; Create account button; Sign in button; Sign out button; Storage usage meter with 2 GB included label; Back up now button; Restore from cloud button; Per-artwork sync status list
- **States:** signed_out, auth_in_progress, auth_error, signed_in_loading_usage, signed_in_ready, backup_in_progress, restore_in_progress, quota_exceeded, sync_error

## Data model

### Entitlement (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Int` | primary key; always 1 |
| isUnlocked | `Boolean` | true after Google Play Billing purchase token is verified locally through BillingClient purchase query |
| productId | `String` | one-time product id: canvasonce_full_unlock |
| purchaseToken | `String` | nullable; stored only for restore reconciliation |
| lastCheckedAt | `Instant` | updated after each BillingClient queryPurchasesAsync call |

### Folder (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| name | `String` | non-empty; unique after case-folding |
| createdAt | `Instant` |  |
| updatedAt | `Instant` |  |
| sortOrder | `Int` | ascending order in Gallery folder chips |

### Artwork (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| folderId | `Long` | nullable; foreign key to Folder.id |
| title | `String` | defaults to Untitled plus date |
| widthPx | `Int` | validated from 64 to 4096 in v1 |
| heightPx | `Int` | validated from 64 to 4096 in v1 |
| dpi | `Int` | validated from 72 to 600 |
| thumbnailPath | `String` | absolute path in app-private files directory |
| documentPath | `String` | absolute path to serialized artwork manifest in app-private files directory |
| createdAt | `Instant` |  |
| updatedAt | `Instant` |  |
| lastAutosavedAt | `Instant` | nullable |
| cloudStatus | `String` | enum string: NOT_CONFIGURED, DIRTY, SYNCING, SYNCED, ERROR |
| cloudManifestId | `String` | nullable; Firestore document id when backed up |

### Layer (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| artworkId | `Long` | foreign key to Artwork.id |
| name | `String` |  |
| bitmapPath | `String` | absolute path to PNG layer bitmap in app-private files directory |
| visible | `Boolean` | default true |
| opacity | `Float` | 0.0 to 1.0 |
| blendMode | `String` | enum string: NORMAL, MULTIPLY, SCREEN, OVERLAY |
| sortOrder | `Int` | 0 is bottom layer |
| updatedAt | `Instant` |  |

### BrushPreset (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; stable built-in brush id such as ink_round_01 |
| name | `String` |  |
| category | `String` | Ink, Pencil, Paint, Marker, or Texture |
| spacing | `Float` | stroke stamp spacing as fraction of brush diameter |
| hardness | `Float` | 0.0 soft to 1.0 hard |
| opacityCurve | `String` | enum string: CONSTANT, PRESSURE_LINEAR, SPEED_FADE |
| textureAsset | `String` | nullable asset path for texture brushes |

### AutosaveSnapshot (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| artworkId | `Long` | primary key; foreign key to Artwork.id |
| snapshotPath | `String` | absolute path to crash-recovery copy in app-private files directory |
| strokeSequenceNumber | `Long` | last committed stroke sequence included in snapshot |
| createdAt | `Instant` |  |
| recovered | `Boolean` | set true after user accepts recovery |

### CloudArtworkManifest (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | Firestore document id |
| ownerUid | `String` | Firebase Auth uid |
| localArtworkId | `Long` | best-effort mapping to local Room Artwork.id on originating device |
| title | `String` |  |
| folderName | `String` | nullable; stored by name to allow restore before local folder id exists |
| widthPx | `Int` |  |
| heightPx | `Int` |  |
| previewStoragePath | `String` | Firebase Storage path for flattened thumbnail PNG |
| documentStoragePath | `String` | Firebase Storage path for zipped artwork document and layer PNGs |
| bytesUsed | `Long` | used for fixed included quota calculation |
| updatedAt | `Instant` | server timestamp on successful backup |

### CloudQuota (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| ownerUid | `String` | primary key; Firebase Auth uid |
| includedBytes | `Long` | 2147483648 bytes, equal to 2 GB included with purchase |
| usedBytes | `Long` | sum of current CloudArtworkManifest.bytesUsed values |
| updatedAt | `Instant` | server timestamp |

## Features

### Single one-time unlock with no second paywall

The app exposes exactly one $12.99 non-consumable purchase that unlocks every v1 feature.

- **Answers complaint:** Paid once, still paywalled

- **Screens:** Unlock, Gallery

- **Estimated hours:** 32

**Implementation notes:** Create one Google Play Billing non-consumable product with id canvasonce_full_unlock and price $12.99. In BillingRepository, initialize BillingClient, call queryProductDetailsAsync for that single product id, and call queryPurchasesAsync(ProductType.INAPP) on app start and after restore. Treat the app as unlocked when a PURCHASED purchase exists for canvasonce_full_unlock and write Entitlement(id=1,isUnlocked=true,productId='canvasonce_full_unlock',purchaseToken,lastCheckedAt) to Room. All navigation decisions must read Entitlement.isUnlocked from Room: if false, start at route unlock; if true, start at route gallery. Do not define subscription products, ads, brush-specific products, cloud products, or feature flags that can lock layers, folders, custom canvas, export, or cloud backup after entitlement is true.

**Acceptance criteria:**
- BillingRepository contains exactly one product id constant: canvasonce_full_unlock.
- No code path checks a subscription product type before opening Editor, Layers, New Canvas, Export, Gallery folders, or Cloud Backup.
- When a fake PURCHASED BillingClient result is injected, app launch navigates to Gallery instead of Unlock.
- When no purchase is present, app launch navigates to Unlock and the Buy button shows one $12.99 purchase option.

### Clear tier messaging

The purchase and store-facing in-app copy explain one tier only: buy once and all v1 features are included.

- **Answers complaint:** Confusing tier structure

- **Screens:** Unlock

- **Estimated hours:** 16

**Implementation notes:** Use the Unlock screen copy exactly as follows: headline 'One price. No second paywall.'; body 'Your purchase includes brushes, layers, folders, custom canvas sizes, export, and 2 GB cloud backup. There is no subscription tier in this app.'; checklist labels '40 built-in brushes', 'Unlimited local folders', 'Custom canvases up to 4096 x 4096', 'Layer blend modes', 'PNG/JPEG export', '2 GB cloud backup'. Do not display words such as Premium Membership, trial, weekly, monthly, ad removal, pro tier, credits, or subscribe anywhere in the app UI.

**Acceptance criteria:**
- Unlock screen contains the headline 'One price. No second paywall.'.
- Unlock screen contains exactly one purchase button.
- A UI text scan of the app source finds no visible strings containing 'Premium Membership', 'weekly', 'monthly', 'ad removal', 'credits', or 'subscribe'.
- The included feature checklist lists cloud backup, folders, custom canvases, layers, brushes, and export.

### Raster canvas and 40-brush engine

Users can draw pressure-aware raster strokes with a fixed, included set of 40 brushes.

- **Answers complaint:** Lead with a smaller but honest brush/filter set.

- **Screens:** Editor, Brush Library

- **Estimated hours:** 144

**Implementation notes:** Implement the Editor canvas with a Compose Canvas inside a transformable state for pan and zoom. Store each layer as an ARGB_8888 Bitmap and render active strokes into the active layer bitmap on a background Dispatchers.Default coroutine. Use PointerInputChange.pressure when available; otherwise default pressure to 1.0. Convert each stroke to stamped circles or texture stamps along the sampled path, with stamp spacing = brush.spacing * currentBrushSize. Seed Room with exactly 40 BrushPreset rows on first launch: 8 Ink, 8 Pencil, 8 Paint, 8 Marker, and 8 Texture. Built-in brush definitions live in app assets and are never downloaded from a remote catalogue, so the brush library cannot disappear if a network call fails.

**Acceptance criteria:**
- A new install seeds exactly 40 BrushPreset rows.
- Drawing a 500-pixel diagonal stroke on the active layer changes non-transparent pixels in the layer bitmap.
- Disabling network access does not change the available brush count.
- Brush size and opacity controls immediately affect the next stroke without reopening the artwork.

### Layers with blend modes

Users can add, delete, reorder, hide, duplicate, and blend raster layers.

- **Answers complaint:** Paid once, still paywalled

- **Screens:** Editor, Layers

- **Estimated hours:** 96

**Implementation notes:** Each Artwork starts with one Layer row and one transparent PNG layer file sized to the canvas. The Layers screen reads layers ordered by sortOrder. Add creates a new transparent bitmap above the current top layer. Delete is disabled when only one layer remains. Reorder updates sortOrder in a Room transaction and then refreshes the renderer. Composite visible layers from bottom to top into a preview bitmap using Android Paint.xfermode equivalents: NORMAL uses SRC_OVER, MULTIPLY uses BlendMode.MULTIPLY on API 29+ and PorterDuff fallback on API 26-28, SCREEN uses BlendMode.SCREEN or fallback, and OVERLAY uses BlendMode.OVERLAY or a pixel loop fallback on API 26-28. Opacity is applied through Paint.alpha = (opacity * 255).roundToInt().

**Acceptance criteria:**
- Every new artwork has exactly one visible Normal layer at 100% opacity.
- The delete button is disabled when only one layer exists.
- After adding two layers and reordering them, closing and reopening the artwork preserves the order.
- Changing a layer to Multiply visibly changes the flattened preview when it overlaps a non-white lower layer.

### Folders and local gallery

Users can organize drawings into local folders without requiring any subscription tier.

- **Answers complaint:** Paid once, still paywalled

- **Screens:** Gallery, New Canvas

- **Estimated hours:** 40

**Implementation notes:** Implement Folder and Artwork Room DAOs. Gallery queries folders ordered by sortOrder and artwork thumbnails filtered by selected folderId, with a null folder represented as 'All artwork'. Moving artwork into a folder updates Artwork.folderId in a Room transaction and immediately re-queries the gallery Flow. Folder deletion must not delete artwork; it sets affected Artwork.folderId to null, then deletes the Folder row. Use Coil AsyncImage with file:// thumbnailPath values for grid thumbnails.

**Acceptance criteria:**
- An unlocked user can create at least three folders without any billing prompt.
- Moving an artwork into a folder makes it appear when that folder chip is selected.
- Deleting a folder leaves its artwork visible under All artwork.
- Relaunching the app preserves folders and artwork-folder assignments.

### Custom and large canvas sizes

Users can create preset or custom canvases up to 4096 by 4096 pixels.

- **Answers complaint:** Paid once, still paywalled

- **Screens:** New Canvas, Editor

- **Estimated hours:** 24

**Implementation notes:** New Canvas validates widthPx and heightPx as integers from 64 through 4096 inclusive and dpi from 72 through 600 inclusive. The custom fields are enabled for every unlocked user. On Create, insert Artwork, create its document directory under filesDir/artworks/{artworkId}, create one transparent layer PNG, create one thumbnail PNG, then navigate to editor/{artworkId}. Reject dimensions whose width * height exceeds 16,777,216 pixels with the message 'Maximum v1 canvas area is 4096 x 4096 pixels.'

**Acceptance criteria:**
- A purchased user can create a 3840 x 2160 canvas without seeing another purchase prompt.
- Entering width 4097 shows a validation error and disables Create.
- Entering height 63 shows a validation error and disables Create.
- A successfully created custom canvas opens in Editor with matching widthPx and heightPx in the Artwork row.

### Included 2 GB cloud backup

Signed-in users can back up artwork documents and thumbnails to Firebase with a fixed included 2 GB quota.

- **Answers complaint:** Ship real (not token) cloud storage with the purchase. 10-image cloud caps behind a subscription is a specific, named complaint — even a generous fixed allotment beats that.

- **Screens:** Cloud Backup, Gallery

- **Estimated hours:** 60

**Implementation notes:** Use Firebase Auth email/password accounts for cloud identity. Store metadata in Firestore collection users/{uid}/artworks and binary files in Firebase Storage paths users/{uid}/artworks/{cloudManifestId}/document.zip and preview.png. Back up now iterates local Artwork rows with cloudStatus DIRTY or NOT_CONFIGURED, zips the artwork manifest plus layer PNG files, computes bytesUsed = zip size + preview size, reads CloudQuota.usedBytes, and uploads only if usedBytes + bytesUsed <= 2,147,483,648. On upload success, write CloudArtworkManifest and update CloudQuota.usedBytes in a Firestore transaction, then mark local Artwork.cloudStatus = SYNCED. Restore lists Firestore manifests, downloads each document.zip, creates missing local Folder rows by folderName, inserts Artwork and Layer rows, and writes files under filesDir/artworks/{newArtworkId}. There is no count-based artwork limit; quota is bytes-based.

**Acceptance criteria:**
- Cloud Backup screen displays '2 GB included' after sign-in.
- Backing up 25 small artwork files succeeds when total bytes are below 2 GB.
- No backup code rejects artwork because more than 10 images already exist.
- When quota would exceed 2,147,483,648 bytes, the upload is skipped and Cloud Backup shows quota_exceeded without corrupting existing cloud manifests.
- Restoring on a fresh install recreates artwork thumbnails and folders from Firestore and Storage.

### PNG and JPEG export

Users can flatten visible layers and export the result as PNG or JPEG.

- **Answers complaint:** baseline parity

- **Screens:** Export, Editor

- **Estimated hours:** 28

**Implementation notes:** Export reads the Artwork and visible Layer rows, composites them bottom-to-top into one ARGB_8888 Bitmap using the same blend pipeline as Editor preview, then writes to app cache directory exports/{artworkId}-{timestamp}.png or .jpg. PNG uses Bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream). JPEG first draws onto an RGB_565 or ARGB_8888 bitmap with an opaque white background if transparent pixels exist, then compresses with the selected quality from 60 to 100. Use FileProvider for the Share button URI, avoiding READ_MEDIA_IMAGES because files remain in app-private cache unless shared.

**Acceptance criteria:**
- Exporting PNG creates a non-empty .png file in cache/exports.
- Exporting JPEG creates a non-empty .jpg file and contains no transparent background.
- Hidden layers are absent from the exported bitmap.
- After export_complete, tapping Share opens an Android share sheet with a content:// URI.

### Autosave and crash recovery

The editor saves recent work frequently and offers recovery after an interrupted session.

- **Answers complaint:** Reliability

- **Screens:** Editor, Gallery

- **Estimated hours:** 40

**Implementation notes:** Maintain a monotonically increasing strokeSequenceNumber per open artwork. After every completed stroke, enqueue a debounced autosave job with 750 ms delay; the job writes the active layer PNG, the artwork manifest JSON, and a recovery copy under filesDir/recovery/{artworkId}.zip, then upserts AutosaveSnapshot. Show 'Saving…' while the job runs and 'Saved' after success. On opening an artwork, if AutosaveSnapshot exists with recovered=false and strokeSequenceNumber greater than the sequence recorded in the main document manifest, show the recoverable_unsaved_snapshot_found state. If the user accepts, unzip the recovery copy over the artwork files inside a transaction, mark recovered=true, regenerate the thumbnail, and open Editor. Because v1 has no animation workspace and no remote brush catalogue, do not implement animation autosave or brush catalogue recovery.

**Acceptance criteria:**
- Completing a stroke creates or updates an AutosaveSnapshot within 2 seconds.
- Force-stopping the app after a stroke and before normal navigation still shows recovery when reopening the artwork.
- Accepting recovery restores the pixels from the interrupted stroke.
- Brush Library still shows 40 built-in brushes after process death and relaunch.

## Store listing

- **Title:** CanvasOnce Draw
- **Short description:** One-time drawing app: brushes, layers, folders, export, cloud. No subscription.
- **Category:** Art & Design
- **Keywords:** drawing app, paint app, digital art, sketch, layers, brushes, cloud backup, one time purchase, no subscription, canvas
- **Icon prompt:** Create a clean Android app icon for a digital drawing app named CanvasOnce Draw. Use a rounded square background in deep blue #2563EB, with a simple white paint brush glyph angled from lower left to upper right and one small canvas corner outline behind it. Flat vector style, high contrast, no text, no gradients, no brand references, centered composition, suitable for Play Store launcher icon.

**Long description:**

CanvasOnce Draw is built around a simple promise: buy once, draw without a second paywall.

Your $12.99 unlock includes the full v1 toolset: 40 built-in brushes, raster layers, blend modes, folders, custom canvases up to 4096 x 4096, PNG/JPEG export, autosave recovery, and 2 GB cloud backup.

There are no ads, no premium membership, no monthly plan, and no separate charge for folders, custom canvas sizes, layers, or cloud backup. The brush set is intentionally focused and included locally, so the tools you bought remain available even when you are offline.

Designed for sketching, painting, and organizing finished artwork on Android.

## Legal

- **Regulated category:** none
- **Privacy policy URL:** https://canvasonce.example.com/privacy (privacy claims verified: no)
- **Data collected:** Email address for Firebase cloud backup sign-in; Firebase user identifier for cloud backup ownership; User-created artwork files uploaded when cloud backup is enabled; Artwork titles and folder names stored in cloud backup manifests; Purchase status and purchase token for one-time unlock restoration

## Test plan

### 1. Paid once, still paywalled (unit)

1. Instantiate BillingRepository with a fake BillingClient returning one PURCHASED INAPP purchase for product id canvasonce_full_unlock.
2. Call refreshEntitlement().
3. Read Entitlement row id 1 from the in-memory Room database.
4. Create a NavigationDecider with that Entitlement and request the start route.
5. Check feature access flags for Editor, Layers, New Canvas, Export, folders, and Cloud Backup.

**Expected:** Entitlement.isUnlocked is true, start route is gallery, and every listed feature access flag is true without any subscription check.

### 2. Confusing tier structure (instrumented)

1. Install a debug build with an empty Room database and fake billing product details for canvasonce_full_unlock priced at $12.99.
2. Launch the app.
3. Wait for the Unlock screen.
4. Find all visible buttons whose text includes purchase, buy, unlock, subscribe, monthly, weekly, ad, or premium.
5. Read all visible body text from the Unlock screen.

**Expected:** Exactly one purchase button is visible, the screen contains 'One price. No second paywall.', and no visible text contains subscribe, monthly, weekly, ad removal, or Premium Membership.

### 3. Paid once, still paywalled: custom canvas sizes (instrumented)

1. Seed Room Entitlement id 1 with isUnlocked true.
2. Launch the app and open Gallery.
3. Tap the New canvas floating action button.
4. Enter width 3840, height 2160, and dpi 300.
5. Tap Create canvas.
6. Query the Artwork table for the most recent artwork.

**Expected:** Editor opens for the new artwork, no purchase screen appears, and the Artwork row has widthPx 3840 and heightPx 2160.

### 4. Paid once, still paywalled: folders (instrumented)

1. Seed Room Entitlement id 1 with isUnlocked true.
2. Launch Gallery.
3. Create folders named Sketches, Finished, and References.
4. Create a new 2048 x 2048 artwork assigned to Finished.
5. Navigate back to Gallery.
6. Tap the Finished folder chip.
7. Tap the Sketches folder chip.

**Expected:** The artwork thumbnail appears under Finished, does not appear under Sketches, and no billing or subscription prompt appears during folder creation or filtering.

### 5. 10-image cloud caps behind a subscription (unit)

1. Create a fake CloudBackupRepository with CloudQuota.usedBytes set to 0 and includedBytes set to 2147483648.
2. Generate 25 fake Artwork records, each producing a 100000-byte document zip and 10000-byte preview.
3. Run backupQueuePlanner.planBackups for all 25 artworks.
4. Count planned upload operations and rejected operations.

**Expected:** All 25 artworks are planned for upload, zero are rejected because of artwork count, and total planned bytes are below the 2 GB quota.

### 6. Reliability: crashes, freezes, and lost brush catalogues (unit)

1. Create an in-memory artwork with one layer and seed the 40 built-in BrushPreset rows.
2. Simulate a completed stroke with strokeSequenceNumber 1.
3. Call AutosaveCoordinator.flushNow().
4. Create a new EditorOpenUseCase instance to simulate process recreation.
5. Open the same artwork.
6. Query BrushPreset count from Room.

**Expected:** EditorOpenUseCase reports recoverable_unsaved_snapshot_found for the artwork and BrushPreset count is still exactly 40.

### 7. baseline parity: layers and blend modes (instrumented)

1. Seed Entitlement as unlocked.
2. Create and open a 512 x 512 artwork.
3. Open Layers.
4. Add a second layer.
5. Set the top layer blend mode to Multiply and opacity to 50%.
6. Draw a dark stroke on the top layer over a colored stroke on the bottom layer.
7. Close and reopen the artwork.
8. Open Layers again.

**Expected:** Two layers are present after reopen, the top layer remains Multiply at 50% opacity, and the editor preview shows the blended overlap.

### 8. baseline parity: export (manual)

1. Install a release build on a physical Android 13 or newer device.
2. Unlock the app with a licensed test account.
3. Create a 2048 x 2048 artwork.
4. Draw on two visible layers and hide one additional layer containing a distinct red mark.
5. Open Export.
6. Export PNG.
7. Tap Share and send the file to the device Files app or another target.
8. Open the exported image.

**Expected:** The exported PNG opens successfully, contains the visible layers, excludes the hidden red mark, and the share sheet used a content URI without requesting media library permission.

### 9. Ship real cloud storage with the purchase (manual)

1. Install the app on Device A and unlock it with a licensed test account.
2. Open Cloud Backup and create an email/password account.
3. Create 12 small artworks in different folders.
4. Tap Back up now and wait for signed_in_ready.
5. Install the app on Device B and unlock it with the same licensed test account.
6. Sign in to the same cloud backup account.
7. Tap Restore from cloud.
8. Return to Gallery.

**Expected:** Device B shows the restored folders and 12 artwork thumbnails; no subscription, membership, or cloud upsell screen appears.

## Build instructions

```sh
set -e
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
export CANVASONCE_KEYSTORE_PASSWORD='CanvasOnceLocalOnly123!'
export CANVASONCE_KEY_ALIAS='canvasonce-release'
export CANVASONCE_KEY_PASSWORD='CanvasOnceLocalOnly123!'
if [ ! -f release.keystore ]; then keytool -genkeypair -v -keystore release.keystore -storepass "$CANVASONCE_KEYSTORE_PASSWORD" -alias "$CANVASONCE_KEY_ALIAS" -keypass "$CANVASONCE_KEY_PASSWORD" -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=CanvasOnce Studio, OU=Solo Builder, O=CanvasOnce Studio, L=San Francisco, ST=CA, C=US"; fi
./gradlew :app:bundleRelease -Pandroid.injected.signing.store.file=$PWD/release.keystore -Pandroid.injected.signing.store.password=$CANVASONCE_KEYSTORE_PASSWORD -Pandroid.injected.signing.key.alias=$CANVASONCE_KEY_ALIAS -Pandroid.injected.signing.key.password=$CANVASONCE_KEY_PASSWORD
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
