# InkShelf Notes — build spec

A genuinely native, offline-first Android stylus notebook with PDF markup and verifiable sync instead of a laggy web-wrapper subscription experience.

## 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:** Goodnotes: AI Notes, Docs, PDF
- **Package id:** `com.goodnotes.android.app`
- **Google Play:** https://play.google.com/store/apps/details?id=com.goodnotes.android.app
- **appy.fyi report:** https://appy.fyi/report/com.goodnotes.android.app
- **Category:** Productivity

## Overview

- **Working name:** InkShelf Notes (trademark cleared: no)
- **Package id:** `com.appyfyi.inkshelfnotes`
- **Min / target SDK:** 26 / 35
- **Backend:** firebase
- **Estimated build time:** 6.95 weeks
- **Pricing:** one-time purchase, $19.99 via `play_billing_direct`
- **Runtime AI:** none
- **Permissions:** `INTERNET`

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

- No attempt to clone the incumbent's full iOS feature set in v1.
- No custom font import in v1; text tools use Android system fonts only.
- No handwriting-to-text, note summarization, or other runtime AI calls in v1.
- No collaborative real-time editing in v1.
- No marketplace, template store, or shared public notebook gallery in v1.
- No audio recording or lecture transcription in v1.

## Tech stack

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

| Purpose | Gradle coordinate |
| --- | --- |
| Compose activity host | `androidx.activity:activity-compose:1.9.3` |
| Compose Material 3 UI components | `androidx.compose.material3:material3:1.3.0` |
| Compose Navigation graph | `androidx.navigation:navigation-compose:2.8.3` |
| Lifecycle-aware Compose state collection | `androidx.lifecycle:lifecycle-runtime-compose:2.8.7` |
| Local-first SQLite persistence | `androidx.room:room-runtime:2.6.1` |
| Room coroutine APIs | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor | `androidx.room:room-compiler:2.6.1` |
| JSON serialization for stroke payloads and sync hashes | `org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3` |
| Firebase account authentication for sync | `com.google.firebase:firebase-auth:23.1.0` |
| Firebase document sync metadata and remote note entities | `com.google.firebase:firebase-firestore:25.1.1` |
| Firebase file storage for imported PDFs and exported notebook files | `com.google.firebase:firebase-storage:21.0.1` |
| Coroutine await support for Firebase Task APIs | `org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0` |
| PDF text extraction for imported-PDF search indexing | `com.tom-roush:pdfbox-android:2.0.27.0` |
| One-time Play Billing unlock | `com.android.billingclient:billing-ktx:7.1.1` |

## Design system

- **Primary color:** `#2457C5`
- **Background color:** `#FAF8F2`
- **Error color:** `#B3261E`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `note-pencil` (regular weight)
- **Theme notes:** Use Material 3 light and dark themes. Light background is warm paper (#FAF8F2) with dark ink text (#1D1B20). Dark theme background is #111318 with primary #9DB7FF. Notebook pages render as #FFFDF7 in light mode and #202124 in dark mode; stroke colors are stored per stroke and are not automatically inverted.

## Screens

### Purchase
- **Route:** `purchase`
- **Purpose:** Explains the one-time unlock and handles Play Billing purchase or restore.
- **Reached via:** first app launch before unlock; tap Unlock from Settings; tap locked premium action after free local trial limit is reached
- **Key UI elements:** App value headline; $19.99 one-time unlock card; Buy once button; Restore purchase button; No subscription reassurance text; Billing error banner
- **States:** loading billing client, not purchased, purchase in progress, purchased, billing unavailable, purchase error

### Library
- **Route:** `library`
- **Purpose:** Shows folders and notebooks stored locally, with sync status badges and creation actions.
- **Reached via:** app launch after purchase check; back from NotebookEditor; back from GlobalSearch; back from SyncStatus; back from Settings
- **Key UI elements:** Top app bar with search and sync buttons; Folder list; Notebook grid; Create folder button; Create notebook floating action button; Per-notebook last edited time; Per-notebook sync badge; Offline available indicator
- **States:** loading, empty, populated, local database error, offline with cached notebooks, sync warning

### NotebookEditor
- **Route:** `notebook/{notebookId}`
- **Purpose:** Native stylus canvas for handwriting, zooming, page navigation, hyperlinks, tabs, and magnification writing.
- **Reached via:** tap notebook tile on Library; tap search result on GlobalSearch; tap conflict notebook from SyncStatus; open newly imported PDF from PdfImport
- **Key UI elements:** Native Compose drawing canvas; Page thumbnail rail; Pen/highlighter/eraser toolbar; Undo and redo buttons; Zoom controls; Magnification writing toggle; Open notebook tabs row; Hyperlink create/edit sheet; Export button; Autosave status text
- **States:** loading notebook, blank first page, populated, saving, offline editing, save error, sync conflict copy open, pdf background rendering error

### PdfImport
- **Route:** `pdf/import`
- **Purpose:** Imports a PDF through Android's document picker, indexes its text, and creates an annotated notebook.
- **Reached via:** tap Import PDF from Library create menu; tap Import PDF from NotebookEditor overflow menu
- **Key UI elements:** Choose PDF button; Selected file name; Import progress indicator; Page count text; Indexing progress text; Open imported notebook button; Import error banner
- **States:** idle, document picker open, copying file, rendering page previews, indexing text, import complete, import error

### GlobalSearch
- **Route:** `search`
- **Purpose:** Searches notebook titles, folder names, hyperlink labels, and extracted PDF page text.
- **Reached via:** tap search icon on Library; tap search icon in NotebookEditor
- **Key UI elements:** Search text field; Result list grouped by notebook; Page number chips; Matched text snippet; Offline search available label; Clear query button
- **States:** empty query, searching, no results, results, search index error

### SignInSync
- **Route:** `sync/signin`
- **Purpose:** Lets the user sign in to enable optional cross-device Firebase sync while keeping local offline access.
- **Reached via:** tap Enable Sync from Library; tap Account & Sync from Settings; tap Resolve Sync from SyncStatus
- **Key UI elements:** Sync explanation text; Email field; Password field; Create account button; Sign in button; Continue local-only button; Authentication error banner
- **States:** signed out, authenticating, signed in, auth error, offline cannot authenticate

### SyncStatus
- **Route:** `sync/status`
- **Purpose:** Shows verifiable sync health, pending operations, conflicts, and last successful remote hash verification.
- **Reached via:** tap sync badge on Library; tap sync status in NotebookEditor; tap Account & Sync from Settings
- **Key UI elements:** Signed-in account row; Last sync time; Pending upload count; Pending download count; Verified hash count; Conflict list; Retry sync button; Local-only mode explanation
- **States:** loading, signed out, all synced, offline pending changes, syncing, sync error, conflicts present

### Settings
- **Route:** `settings`
- **Purpose:** Holds purchase state, account sync entry point, export defaults, and app information.
- **Reached via:** tap settings icon on Library
- **Key UI elements:** Purchase status row; Account & Sync row; Default export format row; Privacy policy link placeholder; App version text
- **States:** loading, populated, billing status error

## Data model

### Folder (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key, UUID |
| name | `String` | non-empty, max 80 characters |
| parentFolderId | `String?` | nullable self-reference to Folder.id |
| createdAt | `Instant` |  |
| updatedAt | `Instant` |  |
| isDeleted | `Boolean` | soft delete for sync |
| remoteVersion | `Long` | last applied Firestore version, 0 when never synced |
| contentHash | `String` | SHA-256 of canonical serialized folder fields excluding sync metadata |

### Notebook (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key, UUID |
| folderId | `String?` | nullable foreign key to Folder.id |
| title | `String` | non-empty, max 120 characters |
| createdAt | `Instant` |  |
| updatedAt | `Instant` | used for sorting and conflict detection |
| lastOpenedPageIndex | `Int` | 0-based |
| isDeleted | `Boolean` | soft delete for sync |
| remoteVersion | `Long` | last applied Firestore version, 0 when never synced |
| contentHash | `String` | SHA-256 of canonical serialized notebook fields excluding sync metadata |

### Page (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key, UUID |
| notebookId | `String` | foreign key to Notebook.id |
| pageIndex | `Int` | 0-based order inside notebook |
| widthPx | `Int` | logical page width at 1.0 zoom |
| heightPx | `Int` | logical page height at 1.0 zoom |
| pdfAttachmentId | `String?` | nullable foreign key to PdfAttachment.id when page has PDF background |
| pdfPageIndex | `Int?` | nullable 0-based PDF page index |
| createdAt | `Instant` |  |
| updatedAt | `Instant` |  |
| isDeleted | `Boolean` | soft delete for sync |
| remoteVersion | `Long` |  |
| contentHash | `String` | SHA-256 of canonical serialized page fields excluding sync metadata |

### Stroke (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key, UUID |
| pageId | `String` | foreign key to Page.id |
| tool | `String` | one of pen, highlighter, eraser |
| colorArgb | `Long` | Android ARGB color packed into Long |
| baseWidthDp | `Float` | stroke width before pressure adjustment |
| pointsJson | `String` | JSON array of points with x:Float, y:Float, pressure:Float, tilt:Float, eventTimeMillis:Long |
| createdAt | `Instant` |  |
| updatedAt | `Instant` |  |
| isDeleted | `Boolean` | soft delete for sync |
| remoteVersion | `Long` |  |
| contentHash | `String` | SHA-256 of canonical serialized stroke fields excluding sync metadata |

### Hyperlink (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key, UUID |
| pageId | `String` | foreign key to Page.id |
| label | `String` | searchable display label |
| rectJson | `String` | JSON object with left, top, right, bottom Float page coordinates |
| targetType | `String` | one of page, url |
| targetPageId | `String?` | nullable Page.id for internal links |
| targetUrl | `String?` | nullable absolute URL for external links |
| createdAt | `Instant` |  |
| updatedAt | `Instant` |  |
| isDeleted | `Boolean` | soft delete for sync |
| remoteVersion | `Long` |  |
| contentHash | `String` | SHA-256 of canonical serialized hyperlink fields excluding sync metadata |

### PdfAttachment (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key, UUID |
| notebookId | `String` | foreign key to Notebook.id |
| displayName | `String` | original file name shown to user |
| localFilePath | `String` | absolute path inside app-specific files directory |
| remoteStoragePath | `String?` | nullable Firebase Storage path after upload |
| pageCount | `Int` |  |
| sha256 | `String` | hash of raw PDF bytes |
| createdAt | `Instant` |  |
| updatedAt | `Instant` |  |
| isDeleted | `Boolean` | soft delete for sync |

### SearchIndex (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key, UUID |
| notebookId | `String` | foreign key to Notebook.id |
| pageId | `String?` | nullable foreign key to Page.id |
| sourceType | `String` | one of notebook_title, folder_name, hyperlink_label, pdf_text |
| plainText | `String` | indexed text |
| snippet | `String` | short display snippet |
| updatedAt | `Instant` |  |

### SyncOperation (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key, UUID |
| entityType | `String` | Folder, Notebook, Page, Stroke, Hyperlink, or PdfAttachment |
| entityId | `String` | id of local entity to sync |
| operationType | `String` | upsert or delete |
| createdAt | `Instant` |  |
| attemptCount | `Int` |  |
| lastError | `String?` | nullable last sync failure message |

### SyncConflict (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key, UUID |
| entityType | `String` |  |
| entityId | `String` |  |
| localHash | `String` |  |
| remoteHash | `String` |  |
| createdAt | `Instant` |  |
| resolution | `String?` | nullable; duplicate_remote_copy or keep_local |

### RemoteNotebookDocument (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| ownerUid | `String` | Firebase Auth uid |
| entityType | `String` | Folder, Notebook, Page, Stroke, Hyperlink, or PdfAttachment |
| entityId | `String` | same UUID as local entity |
| payloadJson | `String` | canonical JSON payload excluding local-only paths |
| contentHash | `String` | SHA-256 of payloadJson |
| version | `Long` | monotonically incremented per entity by client transaction |
| updatedAt | `Instant` | server timestamp when written |
| isDeleted | `Boolean` | soft delete marker |

## Features

### Native low-latency stylus canvas

Provides a native Android handwriting canvas for S-Pen and stylus input with smooth zooming and immediate ink rendering.

- **Answers complaint:** Web-wrapper performance By far the most common complaint: lag while writing/zooming, freezes, and an app that visibly launches into a Chrome tab instead of native code.

- **Screens:** NotebookEditor

- **Estimated hours:** 72

**Implementation notes:** Implement NotebookEditor with Compose Canvas and pointerInteropFilter so the code receives raw MotionEvent objects instead of WebView events. Reject any WebView dependency entirely. For ACTION_DOWN/MOVE/UP, collect current and historical MotionEvent samples with x, y, pressure, getAxisValue(MotionEvent.AXIS_TILT), eventTime, and toolType. Treat TOOL_TYPE_STYLUS and TOOL_TYPE_ERASER as pen/eraser input; allow finger gestures for pan/zoom through transformable state. Render the active stroke from in-memory MutableState immediately on every event before writing to Room. Convert collected points to a smoothed path using Catmull-Rom interpolation converted to quadratic Bezier segments, with stroke width = baseWidthDp * clamp(pressure, 0.35, 1.4). Commit the finished stroke to Room on ACTION_UP inside Dispatchers.IO and enqueue a SyncOperation. Store zoom as Compose state and apply graphicsLayer scale/translation to the page layer; never re-render PDF backgrounds during pinch gestures, only scale cached page bitmaps.

**Acceptance criteria:**
- The app module contains no android.webkit.WebView import and no WebView composable wrapper.
- A stylus ACTION_MOVE with 5 historical samples produces a Stroke.pointsJson containing 6 points including pressure and eventTimeMillis.
- A stroke appears on screen before the Room insert coroutine completes.
- Pinch zoom changes page scale without dropping existing visible strokes.
- Using stylus eraser tool creates an eraser stroke and removes intersecting pen/highlighter stroke segments from the rendered view.

### Offline-first notebook storage

Saves folders, notebooks, pages, strokes, hyperlinks, and imported PDF metadata locally so existing notes remain available without network access.

- **Answers complaint:** Data loss & broken sync Notes and entire notebooks disappearing, hyperlinks that stop working, and no reliable offline access despite an active subscription.

- **Screens:** Library, NotebookEditor, GlobalSearch

- **Estimated hours:** 34

**Implementation notes:** Use Room as the source of truth for all editor and library reads. Create DAOs for Folder, Notebook, Page, Stroke, Hyperlink, PdfAttachment, SearchIndex, SyncOperation, and SyncConflict. Every create/update/delete runs in a Room transaction that updates the entity, recomputes its SHA-256 contentHash from canonical JSON with sorted keys, and inserts a SyncOperation when the user is signed in. Deletes are soft deletes until a successful remote verification has occurred. On app launch, Library renders from Room immediately; network state must not block reads. Hyperlinks are stored as first-class Hyperlink rows with rectangle coordinates and target fields, not embedded inside stroke blobs, so they can be verified and restored independently.

**Acceptance criteria:**
- Creating a notebook, adding one stroke, force-stopping the app, and relaunching shows the notebook and stroke from Room with airplane mode enabled.
- Creating an internal page hyperlink, force-stopping, and relaunching keeps the hyperlink tappable and pointing to the same Page.id.
- Deleting a notebook sets isDeleted=true and creates a SyncOperation instead of immediately removing the row.
- Library populated state appears from local Room data when Firebase is unavailable.

### Verifiable Firebase sync

Optionally syncs local notes across devices with queued operations, content hashes, conflict copies, and visible verification status.

- **Answers complaint:** Local-first storage with verifiable sync. Notes and hyperlinks disappearing is the second-largest cluster; reliability has to be provable, not just claimed.

- **Screens:** SignInSync, SyncStatus, Library, NotebookEditor

- **Estimated hours:** 56

**Implementation notes:** Use Firebase Auth email/password for account identity. Store remote entities in Firestore under users/{uid}/entities/{entityType}_{entityId}; store imported PDF bytes in Firebase Storage under users/{uid}/pdf/{pdfAttachmentId}.pdf and keep remoteStoragePath in Firestore. A SyncRepository observes SyncOperation rows. For each upsert, serialize the current local entity to canonical JSON, compute SHA-256, run a Firestore transaction that reads the remote document, compares remote version/hash to local remoteVersion/contentHash, writes payloadJson/contentHash/version+1 if no conflict, then reads the written document back and verifies the returned hash equals the local hash before deleting the SyncOperation. If both local and remote changed since last synced version, create SyncConflict and duplicate the remote entity tree into a new local notebook titled original title + ' (conflict copy)' instead of overwriting local data. For downloads, query remote documents updated after the last successful sync timestamp, verify contentHash before applying to Room, and never apply a delete unless its hash/version is newer than the local remoteVersion. SyncStatus displays pending counts, last verified hash count, and conflicts.

**Acceptance criteria:**
- When offline, three local edits create three SyncOperation rows and the editor remains usable.
- After network returns, each queued operation is uploaded, read back, hash-verified, and then removed from SyncOperation.
- If remote and local versions both changed for the same notebook, the local notebook is not overwritten and a conflict copy is created.
- A synced hyperlink document downloaded to a fresh install recreates a tappable Hyperlink row with the same targetPageId or targetUrl.
- SyncStatus shows 'all synced' only when pending upload count is 0 and the last uploaded hash has been verified by reading Firestore.

### PDF import, annotation export, and offline search

Imports PDFs, lets users annotate them as notebook pages, exports annotated PDFs, and searches extracted PDF text offline.

- **Answers complaint:** PDF import/export + search

- **Screens:** PdfImport, NotebookEditor, GlobalSearch

- **Estimated hours:** 48

**Implementation notes:** Launch ACTION_OPEN_DOCUMENT with MIME type application/pdf from PdfImport and persist read URI permission. Copy the selected stream into context.filesDir/pdfs/{pdfAttachmentId}.pdf and compute SHA-256 while copying. Use PdfRenderer to get page count and render cached page preview bitmaps at screen resolution for NotebookEditor backgrounds. Use PdfBox-Android PDFTextStripper page-by-page to extract text and insert SearchIndex rows with sourceType=pdf_text and pageId for each imported page. Export annotated PDF by creating an android.graphics.pdf.PdfDocument; for each Page, draw the rendered PDF background bitmap onto the PdfDocument canvas, then draw saved strokes transformed from page coordinates, and add link annotations where Android APIs support only visual link rectangles by drawing a visible underline/label for each Hyperlink. Save the exported file through ACTION_CREATE_DOCUMENT with MIME type application/pdf so no broad storage permission is required.

**Acceptance criteria:**
- Importing a 3-page PDF creates one PdfAttachment, three Page rows, and at least one SearchIndex row per page when text is present.
- Searching for a word extracted from an imported PDF returns a result with the correct notebook title and page number while airplane mode is enabled.
- Exporting an annotated imported PDF through ACTION_CREATE_DOCUMENT produces a PDF file with the same page count as the imported PDF.
- The exported PDF visibly contains the user-created pen strokes on the corresponding pages.
- Canceling the system document picker returns PdfImport to idle state without creating a PdfAttachment row.

### Folders, notebooks, and focused tablet UI polish

Organizes notes into folders and notebooks with a tablet-friendly library and editor layout.

- **Answers complaint:** Folders/notebooks + UI polish

- **Screens:** Library, NotebookEditor

- **Estimated hours:** 28

**Implementation notes:** Library uses a two-pane layout on screens with width >= 840dp: folders on the left and notebook grid on the right; smaller screens use a single list with folder breadcrumbs. Create folder and notebook actions are modal Compose dialogs that validate non-empty names and max lengths before inserting Room rows. NotebookEditor keeps a page thumbnail rail on the left in landscape/tablet widths and collapses it behind a button on compact widths. Show per-notebook sync badge from local SyncOperation/SyncConflict counts: green check when no pending ops or conflicts, amber cloud when pending, red warning when conflicts/errors exist.

**Acceptance criteria:**
- On a 1280dp-wide tablet layout, Library displays folders and notebooks at the same time.
- On a 411dp-wide phone layout, Library displays a single-column folder/notebook list with breadcrumb navigation.
- Creating a folder named 'Math' and a notebook inside it makes the notebook visible only when that folder is selected.
- A notebook with pending SyncOperation rows displays an amber sync badge in Library.
- A notebook with SyncConflict rows displays a red warning badge in Library.

### Tabs and magnification writing

Adds focused Android parity items by allowing multiple open notebook tabs and a magnified writing window.

- **Answers complaint:** iOS feature-parity gap Reviewers who also own an iPad list features (import fonts, tabs, magnification) present on iOS and missing on Android, years after the Android launch.

- **Screens:** NotebookEditor

- **Estimated hours:** 24

**Implementation notes:** Maintain an in-memory EditorSessionState with a list of up to 5 open notebook IDs and the selected notebook ID. The NotebookEditor route can switch notebookId by tapping a tab without leaving the screen; persist only lastOpenedPageIndex per notebook, not the tab list. Magnification mode shows a draggable rectangular source viewport over the page plus a bottom writing panel scaled 2.0x. Stylus coordinates in the magnified panel map back to page coordinates by inverse scale and source viewport offset before being added to the active Stroke. Keep custom font import out of v1 because the app has no rich text document editor beyond labels.

**Acceptance criteria:**
- Opening three notebooks from Library results in three visible tabs in NotebookEditor.
- Tapping a tab switches the canvas to that notebook without losing unsaved active strokes.
- When magnification mode is enabled, drawing at x=100,y=50 in the 2.0x panel stores a point at the corresponding unscaled page coordinate inside the source viewport.
- Dragging the magnification source viewport changes where subsequent magnified strokes are inserted.
- The app does not expose a custom font import button in v1.

### One-time unlock billing

Sells the app as a one-time $19.99 unlock instead of a subscription.

- **Answers complaint:** Paying for a broken build Full subscription price charged on Android while the experience lags the iOS app reviewers are comparing it to.

- **Screens:** Purchase, Settings, Library

- **Estimated hours:** 16

**Implementation notes:** Use Google Play Billing Library with a single in-app product id unlock_full_1999 configured in Play Console at $19.99. On app start, query purchases for INAPP products and cache entitlement in local SharedPreferences only after BillingClient returns PURCHASED and the purchase token has been acknowledged. Purchase screen copy must state 'One-time unlock. No subscription.' If BillingClient is unavailable, allow viewing existing local notebooks but block creating new notebooks beyond any already-created local content until billing can be checked. Restore purchase calls queryPurchasesAsync and updates the cached entitlement.

**Acceptance criteria:**
- Purchase screen displays '$19.99' and the phrase 'One-time unlock. No subscription.'
- A successful PURCHASED result calls acknowledgePurchase for the purchase token before marking entitlement active.
- Restore purchase updates entitlement when queryPurchasesAsync returns unlock_full_1999.
- Settings shows purchased state after entitlement is active.
- No subscription product query is made by the app.

## Store listing

- **Title:** InkShelf Notes
- **Short description:** Native offline S-Pen notes with PDF markup and sync.
- **Category:** Productivity
- **Keywords:** stylus notes, S-Pen notes, PDF annotation, offline notebook, handwriting notes, Android tablet notes, native notes, note sync
- **Icon prompt:** Create a modern Android launcher icon for an app called InkShelf Notes: a rounded square warm paper background (#FAF8F2), a bold blue fountain pen stroke forming a notebook page outline, primary blue #2457C5, subtle ink dot accents, flat vector style, high contrast, no text, no brand logos, centered composition, suitable for Material You adaptive icon foreground.

**Long description:**

InkShelf Notes is a native Android notebook built for stylus-first writing, PDF annotation, and offline reliability.

Write with low-latency ink on a real Android canvas, organize notebooks into folders, import PDFs, search extracted PDF text offline, and keep working when the network disappears. Optional account sync uses visible pending-operation counts, content hashes, and conflict copies so your notes are never silently overwritten.

Built for Android tablets and S-Pen users who want a focused note app without a recurring subscription: unlock once for $19.99.

## Legal

- **Regulated category:** none
- **Privacy policy URL:** https://appyfyi.com/privacy/inkshelf-notes (privacy claims verified: no)
- **Data collected:** Email address for optional Firebase account sync; Firebase user id for optional sync; Notebook, folder, page, stroke, and hyperlink content uploaded only when sync is enabled; Imported PDF files uploaded only when sync is enabled; Purchase status and Play Billing purchase token for one-time unlock entitlement

## Test plan

### 1. Notes and entire notebooks disappearing (unit)

1. Create an in-memory Room database.
2. Insert Folder id folder-1.
3. Insert Notebook id notebook-1 inside folder-1.
4. Insert Page id page-1 inside notebook-1.
5. Insert Stroke id stroke-1 on page-1 with three points.
6. Close and reopen the DAO connection against the same test database.
7. Query notebook-1 with its pages and strokes.

**Expected:** The query returns notebook-1, page-1, and stroke-1 with the same ids and stroke point count; no entity is missing.

### 2. Hyperlinks that stop working (unit)

1. Create two Page rows: page-a and page-b in the same notebook.
2. Insert Hyperlink id link-1 on page-a with targetType page and targetPageId page-b.
3. Run the canonical hash computation for link-1.
4. Serialize link-1 to sync payload JSON.
5. Deserialize the payload into a new Hyperlink instance.
6. Insert the deserialized hyperlink into a fresh in-memory Room database.
7. Query hyperlinks for page-a.

**Expected:** The restored hyperlink has targetType page, targetPageId page-b, the same rectJson, and is returned by the page-a query.

### 3. Broken sync overwrites local notes (unit)

1. Create local Notebook id notebook-1 with remoteVersion 3, title 'Local title', and local contentHash A.
2. Create simulated remote Firestore payload for notebook-1 with version 4, title 'Remote title', and contentHash B.
3. Create a pending local SyncOperation for notebook-1 representing a local update after version 3.
4. Run SyncRepository conflict detection with the simulated remote document.
5. Query local Notebook rows and SyncConflict rows.

**Expected:** The original local notebook still has title 'Local title', a separate conflict copy exists with title containing '(conflict copy)', and one SyncConflict row records localHash A and remoteHash B.

### 4. App visibly launches into a Chrome tab instead of native code (instrumented)

1. Install and launch the debug app with ActivityScenario.
2. Wait for the first screen to settle.
3. Inspect the view hierarchy for android.webkit.WebView instances.
4. Navigate from Library to NotebookEditor.
5. Inspect the view hierarchy again for android.webkit.WebView instances.

**Expected:** No WebView instance exists on launch or inside NotebookEditor.

### 5. Lag while writing and zooming (instrumented)

1. Open NotebookEditor with a blank notebook.
2. Inject a stylus MotionEvent ACTION_DOWN at 100,100.
3. Inject an ACTION_MOVE containing five historical points ending at 180,180.
4. Read the active stroke state before forcing Room idle.
5. Perform a pinch zoom gesture on the canvas.
6. Read the canvas scale state and visible stroke count.

**Expected:** The active stroke state contains six points before Room persistence completes, canvas scale is not 1.0 after pinch, and the visible stroke count remains at least one.

### 6. No reliable offline access despite an active subscription (instrumented)

1. Create a notebook with one page and one stroke while online.
2. Navigate back to Library.
3. Disable network for the test device or use a fake repository reporting offline.
4. Force-stop and relaunch the app.
5. Open the same notebook from Library.

**Expected:** Library shows the notebook in offline cached state and NotebookEditor renders the saved stroke without requiring network.

### 7. PDF import/export + search baseline parity (instrumented)

1. Provide a test PDF asset with three pages and the unique word 'calculus' on page 2.
2. Launch PdfImport and feed the asset URI through the document picker test hook.
3. Wait until import complete.
4. Open GlobalSearch and search for 'calculus'.
5. Tap the result.
6. Export the notebook to a test output URI.

**Expected:** Search returns a result for page 2, tapping it opens NotebookEditor on that page, and the exported PDF has exactly three pages.

### 8. Paying full subscription price for a broken app (manual)

1. Open a fresh install without entitlement.
2. Navigate to Purchase.
3. Read the price and billing copy.
4. Start the purchase flow using a Play Billing license tester.
5. Complete the purchase.
6. Open Settings.

**Expected:** Purchase copy says '$19.99' and 'One-time unlock. No subscription.', the Play flow is for in-app product unlock_full_1999, and Settings shows purchased after completion.

### 9. iOS feature-parity gap for tabs and magnification (manual)

1. Create three notebooks from Library.
2. Open the first notebook, then open the second and third so they appear as tabs.
3. Switch between all three tabs.
4. Enable magnification writing mode.
5. Draw a short stroke in the magnified writing panel.
6. Disable magnification and inspect the page.

**Expected:** All three notebooks are available as tabs, switching tabs preserves their pages, and the magnified-panel stroke appears on the selected source area of the normal page.

## Build instructions

```sh
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
keytool -genkeypair -v -keystore release.keystore -alias release -keyalg RSA -keysize 2048 -validity 10000 -storepass changeit -keypass changeit -dname "CN=InkShelf Notes,O=AppyFyi,C=US"
./gradlew bundleRelease -Pandroid.injected.signing.store.file=$PWD/release.keystore -Pandroid.injected.signing.store.password=changeit -Pandroid.injected.signing.key.alias=release -Pandroid.injected.signing.key.password=changeit
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
