# Technical Decisions Record
> Vibe Reader - Architecture Decision Records (ADRs): the "why" behind significant technical choices
> Last Updated: 2026-08-26 · Ordered newest first (add new TDRs at the top)
## Format
Each TDR: **Context** → **Options Considered** → **Decision** → **Consequences**, with **Status:** `Accepted` | `Superseded` | `Proposed`.
---
## TDR-014: Durable Local Data Across Dev Builds (Migrations + Signing Parity)
**Date:** 2026-08-26 · **Status:** Accepted (applied 2026-08-26; first-build verification pending) · **Related:** [[#TDR-001]], [[#TDR-011]], [[Project - Vibe Reader/Build & Iterate/Bug Tracker#BUG-012|Bug Tracker > BUG-012]]
### Context
Routine dogfooding and a growing tester population mean the app is now accumulating data worth keeping - and rapid iteration keeps destroying it. Two independent causes wipe the Room database between builds:
1. **Destructive migration fallback.** TDR-001 shipped with `fallbackToDestructiveMigration()`: every schema version bump nukes the database and rebuilds it empty. Fine for a solo debug loop; fatal for anyone's accumulated library.
2. **Signing mismatch between install sources.** Android Studio signs with the local debug keystore; CI (TDR-011) signs with the runner's generated one. Installing one over the other fails the signature check, forcing an uninstall - which wipes app data. The two redeploy paths silently cannot coexist on one device.
A normal reinstall (`Run` / `adb install -r`) preserves `/data/data/` - persistence is the default. We opted out of it twice without noticing.
### Options Considered
| Option | Description | Pros | Cons |
|--------|-------------|------|------|
| **A. Status quo** | Keep destructive fallback; testers re-seed | Zero effort | Kills every tester's library on schema change; trust-ending |
| **B. Room auto-migrations** | `exportSchema = true` + `autoMigrations`; hand-written `Migration` only for complex changes | Room generates ALTERs for additive changes; low ongoing cost | Schema JSON files must be committed; discipline required |
| **C. Export/import layer only** | JSON backup/restore, no migrations | Also covers device swaps | Manual step; does not fix the default path |
### Decision
**Option B, plus signing parity, plus C as the escape hatch:**
1. **Real migrations.** Enable `exportSchema = true` (schema location wired into KSP args, JSON committed to the repo) and declare `autoMigrations` for additive changes. `fallbackToDestructiveMigration()` is removed. The upcoming `weekly_vibes` table is the first migration written under the new regime.
2. **One debug keystore everywhere.** The local `debug.keystore` goes into a CI secret (base64); the workflow signs with it, so CI artifacts and Android Studio builds update over each other cleanly.
3. **Debug-only export/import.** A JSON dump/restore of the full database as the disaster-recovery path (and the seed of the user-facing "Export Session" backlog item).
### Consequences
- ✅ "Reupload without losing the library" becomes true in both directions (local ↔ CI builds)
- ✅ Tester data survives schema evolution - a precondition for expanding the tester population
- ✅ Committed schema history doubles as documentation of the data model's evolution
- ⚠️ Never reuse a schema version number once shipped to any tester; version bumps become deliberate acts
- ⚠️ Keystore-in-CI is a (low-value, debug-only) secret to manage
- 📋 Verification: build N → capture data → build N+1 with a schema change → data intact, from both install paths
---
## TDR-013: Bubbles API for the Roaming Capture Surface
**Date:** 2026-08-12 · **Status:** Accepted (Slice 2; not yet built) · **Related:** [[#TDR-008]], [[#TDR-012]]
### Context
Roaming needs a capture trigger that lives outside the app while the phone is *unlocked* and in use (a Whispr Flow-style floating chip). TDR-008 dismissed the Bubble API for the *reading* flow because bubbles collapse when the device locks. Roaming inverts that constraint: out and about, the phone is typically unlocked, and lock screen support is not the requirement.
### Options Considered
| Option | Description | Pros | Cons |
|--------|-------------|------|------|
| **A. Bubbles API (API 30+)** | Conversation notification + bubble metadata; the OS renders the floating chip; tapping expands the capture activity in a small window | Play-Store-safe; OS-managed UX; the stronger portfolio story | API 30+ only; OEM/launcher inconsistency risk |
| **B. SYSTEM_ALERT_WINDOW overlay** | Draw-over-apps floating button | Works everywhere | Scary permission; Play Store scrutiny; hand-rolled window management |
### Decision
**Option A: Bubbles API.** The overlay route is held only as a fallback experiment if bubbles prove flaky on real devices.
Implementation shape (Slice 2): conversation shortcut, bubble metadata on the roam notification, capture activity presented in the bubble window. The toggle lives in the new Settings pane.
### Consequences
- ✅ Sanctioned API; no draw-over-apps permission; C2C 1 while the phone is in use
- ✅ Complements rather than replaces the reading-session triggers (tile / MediaStyle)
- ⚠️ Requires the Settings pane to exist (roaming/bubble toggle; also the natural migration home for code-level constants like `USE_MEDIA_STYLE`)
- ⚠️ Bubble behavior varies by OEM and launcher; needs real-device validation
---
## TDR-012: Roaming as a Reserved Book (Day-Rollup Sessions)
**Date:** 2026-08-12 · **Status:** Accepted (Slice 1 built; pending device test + commit) · **Related:** [[#TDR-001]], [[Project - Vibe Reader/Build & Iterate/Feature Backlog|Feature Backlog]], [[Project - Vibe Reader/Build & Iterate/User Feedback#Session-006|User Feedback > Session-006]], Product Log 2026-08-12
### Context
Roaming ("Background Vibe") extends capture beyond the book: a word in a podcast, a phrase in conversation, a line from an article. The question was how to store captures that do not belong to a book without fracturing the Book → Session → Captures hierarchy (TDR-001).
### Options Considered
| Option | Description | Pros | Cons |
|--------|-------------|------|------|
| **A. Nullable book_id** | Captures allowed to exist with no book | Minimal concept count | Every query and view grows a null branch; breaks drill-down assumptions |
| **B. Parallel storage path** | Separate roam tables and views | Clean separation | Duplicated UI + DAO logic; two systems to maintain |
| **C. Reserved Book row** | Roaming is a special Book; its sessions are calendar days | Entire existing hierarchy works untouched | Reserved title needs routing and shelf exclusions |
### Decision
**Option C: Reserved Book row with day-rollup sessions.**
- `ROAMING_BOOK_TITLE` reserved row; `startRoam()` implements day rollup: one roam session per calendar day, and pausing + resuming on the same day rejoins that day's roam
- Sessions are days ("Aug 12"); days become stops on the trail; book detail view, session metrics, and drill-down all work unchanged
- No schema migration needed: Words/Quotes already carry `book_id`
- Library entry points: slim Roaming bar + All Captures bar pinned topmost, visually separated from book cards; Roaming excluded from the book shelf and Resume chips
### Consequences
- ✅ Zero migration; zero parallel code paths; full reuse of hierarchy, metrics, and drill-down
- ✅ All Captures view falls out naturally (`getAllWordsWithBook` / `getAllQuotesWithBook` joins, book pills on cards)
- ⚠️ Reserved title requires routing (typing "Roaming" as a book title routes to the roam flow) and shelf exclusions
- ⚠️ Duration is misleading for roams (spans include pause gaps): hide or reframe for the Roaming shelf
- 📋 Open: does a roam "end," or is it ambient until midnight? Current build: manual end, same-day restart resumes
- 📋 Open: roams appear in the History trail (they are sessions); consider distinct styling
---
## TDR-011: Remote Iteration, CI Compilation (The Build Pipeline)
**Date:** 2026-08-01 · **Status:** Accepted · **Related:** [[Project - Vibe Reader/Build & Iterate/Bug Tracker#BUG-R011|Bug Tracker > BUG-R011]], Product Log 2026-08-01
### Context
Code iteration happens in a cloud workspace that cannot compile Android: Google's SDK and Maven servers are blocked at the network layer. Verification there is static only (code review, ktlint parse-checks). A reliable compile-and-deploy loop was needed that does not depend on a hand-configured local toolchain being awake.
### Decision
- **GitHub Actions CI:** every push to main builds a debug APK and uploads it as an artifact. The redeploy loop is push → wait → sideload.
- **Static gate in the cloud:** ktlint parse-checks before push (they caught two real errors from the SessionComponents split; the gate earned its keep).
- **Repo hygiene to protect the loop:** `.gitattributes` + autocrlf, ending the CRLF phantom-diff class of problem for good (see [[Project - Vibe Reader/Build & Iterate/Bug Tracker#BUG-R011|Bug Tracker > BUG-R011]]).
### Consequences
- ✅ Fast iteration where iteration is cheap; guaranteed compilation where it is reliable
- ✅ Every push produces an installable artifact, no local build required
- ⚠️ The compile gate is deferred: type errors surface at first local build or CI run, not edit time
- 📋 Optional: add `GEMINI_API_KEY` as a repo secret so CI builds ship with a working Weekly Vibe
---
## TDR-010: MediaStyle "Tuxedo" Reinstated as a Flagged Experiment
**Date:** 2026-08-01 · **Status:** Accepted (device verification pending) · **Related:** [[#TDR-002]], [[#TDR-008]], Product Log 2026-08-01
### Context
TDR-008 concluded the MediaSession "Trojan Horse" was unsustainable and moved the app to the Quick Settings Tile + standard notification. That remains the proven baseline. But the C2C math never stopped nagging: the tile costs 2 taps; a lock screen ▶ costs 1. The February failures may have had a specific, addressable cause: the notification was not a *convincing enough* media player for the OS to render its controls.
### Decision
Re-implement the MediaStyle notification with the full costume, behind a one-line fallback flag:
- Full metadata: title (book name), artist line ("tap ▶ to capture"), album art
- Active session held at `PlaybackState.STATE_PAUSED` so the lock screen shows a prominent ▶
- ▶ launches Smart Capture; Stop ends the session
- `USE_MEDIA_STYLE` flag falls back to the proven standard notification if the widget goes invisible again
### Consequences
- ✅ Restores a credible path to C2C 1 from the lock screen without betting the product on it
- ✅ One-line rollback; the tile remains the reliable floor either way
- ⚠️ Unverified on device: the dogfood test (does ▶ render on the Pixel lock screen?) decides the capture entry-point story
- ⚠️ Still platform-adjacent territory; Google may tighten further (TDR-008's analysis stands)
- 📋 The flag belongs in the upcoming Settings pane rather than in code (see [[#TDR-013]])
---
## TDR-009: Asymmetric Verification (Words vs. Quotes)
**Date:** 2026-02-09 · **Status:** Accepted · **Related:** [[Project - Vibe Reader/Build & Iterate/Feature Backlog#Skip Confirmation|Feature Backlog > Skip Confirmation]], [[Project - Vibe Reader/Build & Iterate/Feature Backlog#Auto-Dismiss Definition|Feature Backlog > Auto-Dismiss Definition]], [[Project - Vibe Reader/Build & Iterate/User Feedback#Session-001|User Feedback > Session-001]]
### Context
Smart Capture mode required verification (VERIFYING state) for all inputs, whether 1-word definitions or multi-word quotes. User feedback (Session-001) flagged this as unnecessary friction for words. The question: should verification be skipped for all modes, or only for words?
### Decision
**Asymmetric verification: skip for words, keep for quotes.**
Words skip the VERIFYING screen and go directly to `defineWord()`. The definition screen auto-dismisses after 5 seconds with an opt-out "Keep Open" button. Quotes (3+ words) retain the full verification flow with TTS playback.
### Rationale
The error consequences are asymmetric:
- **Wrong word definition:** User sees it immediately and the cost is low (glance, recognize the error, retry next time). A wrong definition does not persist as useful data.
- **Wrong quote transcription:** User may not notice until later. A misheard sentence saved to the Library looks like a real capture. TTS playback catches these errors in-flow.
Since error visibility and cost differ, the friction investment should differ too. Spending 3 taps to verify "hegemony" wastes time. Spending 3 taps to verify a 15-word quote prevents bad data.
### Implementation
- `handleSpeechResult()`: SMART_CAPTURE branch calls `defineWord(text)` directly for wordCount <= 2
- DEFINING composable: `LaunchedEffect` countdown (5s) with `autoDismissActive` state
- `startListening()` resets `autoDismissActive = true` on each new capture cycle
### Consequences
- ✅ Word definition C2C drops from 4 interactions to 0 taps
- ✅ Quote verification unchanged (TTS playback still catches transcription errors)
- ✅ "Keep Open" button preserves user control for edge cases
- ⚠️ If speech recognition mishears a word, user sees the wrong definition briefly before auto-dismiss. Acceptable because the word is still saved and can be re-looked up from the Library.
---
## TDR-008: Lock Screen Trigger Architecture (The Platform Constraint)
**Date:** 2026-02-06 · **Status:** Accepted (2026-02-07); amended by TDR-010 (2026-08-01) · **Related:** [[Project - Vibe Reader/Build & Iterate/Bug Tracker#BUG-R008|Bug Tracker > BUG-R008]], [[Project - Vibe Reader/Build & Iterate/Feature Backlog#Quick Settings Tile|Feature Backlog > Quick Settings Tile]]
### Context
The core product hypothesis requires capturing words/quotes from the lock screen with minimal friction. The current "Trojan Horse" MediaSession approach (TDR-002) has proven unreliable due to Android platform restrictions that are actively tightening.
**The Recurring Problem:**
1. Android 12 removed `ACTION_CLOSE_SYSTEM_DIALOGS` (notification shade does not collapse)
2. Android 13+ introduced strict Background Activity Launch Restrictions
3. Google continues to crack down on "fake media players" that abuse MediaSession
The MediaSession approach may work today, break tomorrow. This is not a sustainable foundation for the product's core interaction pattern.
### North Star Alignment
The product mantra is "Keep your vibe, no interruptions." The key metric is **C2C (Clicks-to-Capture)**:
| Trigger Method | C2C | Flow |
|----------------|-----|------|
| MediaSession (ideal) | 1 | Lock screen → Tap ▶ → Listening |
| MediaSession (actual) | 1-∞ | Lock screen → Tap ▶ → Maybe works? |
| Quick Settings Tile | 2 | Pull shade → Tap tile → Listening |
| Bubble (unlocked) | 1 | Tap bubble → Listening |
| Full-screen Intent | 1 | Lock screen → Tap notification → Full takeover |
### Options Analysis
```
LOCK SCREEN TRIGGER ARCHITECTURE
═══════════════════════════════
Current State The Fork in the Road
───────────── ────────────────────
┌─────────────────┐ ┌─────────────────────────────┐
│ MediaSession │ │ OPTION A: Full-Screen │
│ "Trojan Horse" │──────┬────────────▶│ Intent (Alarm-Style) │
│ │ │ │ C2C: 1 | Lock: ✓ │
│ ⚠️ Unreliable │ │ │ Maintenance: Low │
│ ⚠️ Shade stays │ │ │ UX: Intrusive takeover │
│ ⚠️ Google │ │ └─────────────────────────────┘
│ locking down │ │
└─────────────────┘ │ ┌─────────────────────────────┐
│ ├────────────▶│ OPTION B: Quick Settings │
│ │ │ Tile │
▼ │ │ C2C: 2 | Lock: ✓ │
┌─────────────────┐ │ │ Maintenance: Very Low │
│ WE ARE HERE │ │ │ UX: Reliable, standard │
│ BUG-001 │ │ └─────────────────────────────┘
│ Critical │ │
└─────────────────┘ │ ┌─────────────────────────────┐
├────────────▶│ OPTION C: Bubble API │
│ │ (Android 11+) │
│ │ C2C: 1 | Lock: ✗ │
│ │ Maintenance: Medium │
│ │ UX: Floating, persistent │
│ └─────────────────────────────┘
│
│ ┌─────────────────────────────┐
├────────────▶│ OPTION D: Hybrid │
│ │ MediaSession + QS Fallback │
│ │ C2C: 1-2 | Lock: ✓ │
│ │ Maintenance: High │
│ │ UX: Best when works │
│ └─────────────────────────────┘
│
│ ┌─────────────────────────────┐
└────────────▶│ OPTION E: Accessibility │
│ Service │
│ C2C: 0 | Lock: ✓ │
│ Maintenance: Low │
│ UX: Scary permissions │
│ ⚠️ Play Store risk │
└─────────────────────────────┘
```
### Detailed Option Breakdown
#### Option A: Full-Screen Intent (Alarm-Style)
**How it works:** Use `Notification.Builder.setFullScreenIntent(pendingIntent, true)` with a high-priority notification channel. This mimics incoming call/alarm behavior.
| Attribute | Assessment |
|-----------|------------|
| Lock Screen | ✓ Works reliably, system-guaranteed |
| Shade Collapse | ✓ Auto-dismisses notification shade |
| C2C | 1 (tap notification) |
| Maintenance | Low (stable API, used by system apps) |
| Scalability | High (no ongoing platform battles) |
| UX Trade-off | Feels intrusive; full screen takeover |
| Permissions | Requires `USE_FULL_SCREEN_INTENT` (already have) |
**Verdict:** Most technically sound. UX concern is the takeover feel, but this is how alarm clocks and phone apps work. Users understand the pattern.
#### Option B: Quick Settings Tile
**How it works:** Register a `TileService` that appears in the notification shade quick settings. User taps tile to trigger capture.
| Attribute | Assessment |
|-----------|------------|
| Lock Screen | ✓ Accessible from lock screen shade |
| Shade Collapse | ✓ Tile tap launches activity, shade collapses |
| C2C | 2 (pull shade + tap tile) |
| Maintenance | Very Low (stable since Android 7.0) |
| Scalability | High (no platform restrictions) |
| UX Trade-off | Extra step; user must add tile manually |
| Permissions | None special required |
**Verdict:** Most reliable fallback. The extra tap is a real cost, but it *always works*. Could be positioned as "power user mode" or default for users who experience MediaSession issues.
#### Option C: Bubble API
**How it works:** Use `Notification.BubbleMetadata` to create a floating overlay that persists across apps.
| Attribute | Assessment |
|-----------|------------|
| Lock Screen | ✗ Bubbles collapse when device locks |
| Shade Collapse | N/A (not in shade) |
| C2C | 1 (when visible) |
| Maintenance | Medium (API evolving) |
| Scalability | Medium (Android 11+ only) |
| UX Trade-off | Persistent floating icon may feel intrusive |
| Permissions | None special required |
**Verdict:** Does NOT solve lock screen problem. Useful only for quick capture while actively using phone (between reading sessions). Not a primary solution.
#### Option D: Hybrid (MediaSession + Quick Settings Fallback)
**How it works:** Keep MediaSession as primary with Quick Settings Tile as documented fallback. Guide users to tile if they experience issues.
| Attribute | Assessment |
|-----------|------------|
| Lock Screen | ⚠️ Partial (depends on device state) |
| C2C | 1-2 (varies) |
| Maintenance | High (two systems to maintain) |
| Scalability | Low (MediaSession may break further) |
| UX Trade-off | Inconsistent experience |
**Verdict:** Pragmatic short-term but not sustainable. Technical debt accumulates as Google continues tightening restrictions.
#### Option E: Accessibility Service
**How it works:** Register as an accessibility service, gain ability to launch activities from any state without restrictions.
| Attribute | Assessment |
|-----------|------------|
| Lock Screen | ✓ Works anywhere, no restrictions |
| C2C | 0 (could intercept gestures) |
| Maintenance | Low (stable API) |
| Scalability | High (no platform battles) |
| UX Trade-off | Scary permission dialog; user trust issue |
| Play Store | ⚠️ Policy risk; must justify accessibility use |
**Verdict:** Nuclear option. Ultimate power but real trust and policy risks. Only consider if all else fails and user research shows willingness to grant permission.
### Recommendation
**Short-term (Next Sprint):**
1. Implement **Full-Screen Intent** with `highPriority=true` as primary fix for BUG-001
2. Test shade collapse behavior and lock screen launch reliability
3. If intrusive feel is problematic, add brief countdown before auto-launch
**Medium-term (V1 Release):**
1. Implement **Quick Settings Tile** as first-class alternative
2. Add onboarding prompt: "Add Vibe Reader to Quick Settings for reliable capture"
3. Deprecate MediaSession play button in favor of tile
**Long-term (Post-V1):**
1. Monitor Google's platform direction
2. If MediaSession continues degrading, remove it entirely
3. Consider Accessibility Service only if user research supports it
### Consequences
- ✅ Unblocks critical lock screen reliability issue
- ✅ Reduces maintenance burden (stop fighting Android)
- ⚠️ C2C increases from 1 to 2 for Quick Settings path
- ⚠️ Full-screen intent may feel jarring to some users
- 📋 TODO: User test both approaches to measure perceived friction
---
## TDR-007: Weekly Vibe Redesign - Structured Output & Safeguards
**Date:** 2026-02-05 · **Status:** Accepted · **Related:** [[Project - Vibe Reader/Build & Iterate/Feature Backlog#Weekly Vibe Redesign|Feature Backlog > Weekly Vibe Redesign]]
### Context
The initial Weekly Vibe implementation (TDR not documented) used prose-based output from Gemini:
- **Problem 1:** Verbose 3-paragraph prose was hard to scan; did not feel like a "vibe"
- **Problem 2:** High token usage (~1024 output tokens, ~200+ input tokens for definitions)
- **Problem 3:** No safeguard against redundant API calls (user could spam "Generate" with no new data)
Goal: Create a Spotify Wrapped-style experience that is visually engaging, token-efficient, and prevents wasteful API calls.
### Options Considered
**Output Format:**
| Option | Description | Pros | Cons |
|--------|-------------|------|------|
| **A. Prose** | Free-form paragraphs (current) | Natural language; flexible | Hard to style; inconsistent length |
| **B. Structured JSON** | Request specific fields in JSON format | Predictable UI; constrained output | Requires parsing; model may hallucinate structure |
| **C. Markdown with headers** | Semi-structured with `##` sections | Easy to parse | Still variable length; harder to style |
**Safeguard Strategy:**
| Option | Description | Pros | Cons |
|--------|-------------|------|------|
| **A. Cooldown timer** | Disable button for X minutes after generation | Simple | Arbitrary; blocks legitimate re-generation |
| **B. Data fingerprint** | Hash captures; only regenerate if hash changes | Precise | Complex; hash computation overhead |
| **C. Session count tracking** | Track session count at generation time; enable if new sessions | Simple; meaningful | Does not catch new captures within same session |
### Decision
**Output: Option B (Structured JSON)**
New prompt strategy:
- Input: Top 10 word terms (no definitions), top 5 quotes (truncated to 50 chars)
- Output: Strict JSON schema with constrained fields
- Config: `maxOutputTokens: 256` (down from 1024), `temperature: 0.5` (down from 0.7)
JSON schema requested:
```json
{
"vibe_title": "2-3 word theme title",
"vibe_emoji": "single emoji",
"theme_tags": ["tag1", "tag2", "tag3"],
"insights": ["insight1 (max 12 words)", "insight2"],
"word_spotlight": "most interesting word",
"quote_spotlight": "evocative quote (max 60 chars)"
}
```
**Safeguard: Option C (Session count tracking)**
Implementation:
- `lastVibeSessionCount: Int` - stores session count at last generation
- `lastVibeTimestamp: Long` - stores generation timestamp
- `canGenerateVibe: StateFlow<Boolean>` - derived state for UI
Button states:
- `CAN_GENERATE` → Primary button, enabled
- `GENERATING` → Disabled with spinner
- `UP_TO_DATE` → Muted outline with checkmark ("Vibe is current")
- `NO_DATA` → Disabled with lock icon ("Start reading to unlock")
### Consequences
- ✅ ~60-70% token reduction per API call
- ✅ Consistent, predictable UI layout
- ✅ Prevents accidental API spam
- ✅ Visual feedback on button state communicates system status
- ⚠️ JSON parsing requires try-catch fallback (model may output malformed JSON)
- ⚠️ Session count safeguard does not detect new captures within existing sessions (acceptable trade-off)
- 📋 Future: Could add "Force Regenerate" option for power users
### Files Modified
- `WeeklyVibe.kt` - New structured data model
- `GeminiClient.kt` - JSON prompt + parsing + VibeResponse data class
- `SessionViewModel.kt` - Safeguard state tracking + canGenerateVibe flow
- `SessionComponents.kt` - Wrapped-style card UI + button states
- `ReviewScreen.kt` - Wire canGenerateVibe to LibraryView
---
## TDR-004: Dependency Injection Pattern
**Date:** 2026-01-10 · **Status:** Accepted · **Related:** Product Log 2025-11-12
### Context
Initial implementation used Hilt for dependency injection. Build failures occurred due to KSP/Kotlin version mismatches, consuming significant development time.
### Options Considered
| Option | Description | Pros | Cons |
|--------|-------------|------|------|
| **A. Hilt** | Google's recommended DI framework | Powerful; scalable; Android-aware | Complex setup; version sensitivity; slower builds |
| **B. Koin** | Lightweight Kotlin DI | Simpler than Hilt; less boilerplate | Still a dependency; runtime vs compile-time |
| **C. Manual DI / Singleton** | Hand-rolled dependency management | Zero dependencies; full control; fast builds | More boilerplate; must manage lifecycle manually |
### Decision
**Option C: Manual DI / Singleton Pattern**
Implementation:
- `AppDatabase.getDatabase(context)` - Singleton Room instance
- `SessionViewModelFactory` - Manual factory for ViewModel injection
- Direct instantiation in `MainActivity` and `ReadingSessionService`
### Consequences
- ✅ Build stability restored immediately
- ✅ Simpler codebase; easier to understand
- ✅ Faster build times
- ⚠️ Must manually ensure singleton lifecycle
- ⚠️ May need to revisit if app grows significantly
- 📝 Lesson: For MVPs, prefer simplicity over "best practices"
---
## TDR-003: Dictionary API Strategy
**Date:** 2026-01-18 · **Status:** Accepted · **Related:** [[Project - Vibe Reader/Build & Iterate/Feature Backlog#Multi-Word Concept Support|Feature Backlog > Multi-Word Concept Support]]
### Context
Users want to define:
1. **Single words** (e.g., "hegemony") - standard dictionary lookup
2. **Multi-word phrases** (e.g., "carpe diem", "ad hoc") - may exist in dictionary
3. **Concepts/entities** (e.g., "United States", "Pythagorean theorem") - not in dictionary
Current implementation uses Free Dictionary API which handles (1) and some of (2), but fails on (3).
### Options Considered
| Option | Description | Pros | Cons |
|--------|-------------|------|------|
| **A. Dictionary API only** | Free Dictionary API for all lookups | Simple; fast | Fails on concepts and many phrases |
| **B. Dictionary → Wikipedia fallback** | Try dictionary first; if 404, try Wikipedia API | Covers most cases | Two API calls for concepts; latency |
| **C. Wikipedia only** | Use Wikipedia for everything | Comprehensive | Overkill for simple words; slower |
| **D. AI-powered definition** | Send to LLM for definition | Handles anything | Latency; cost; requires API key |
| **E. User choice** | Show "Define" vs "Look up" buttons | User controls intent | Adds friction; more UI complexity |
### Decision
**Option B: Dictionary → Wikipedia fallback**
### Implementation (2026-01-18)
New files created:
- `WikipediaApiService.kt` - Retrofit interface for Wikipedia REST API
- `WikipediaClient.kt` - Singleton client for `https://en.wikipedia.org/api/rest_v1/`
Modified `SpeechCaptureActivity.kt`:
- Added "Define This Instead" TextButton (only visible for detected quotes)
- Updated `defineWord()` with fallback chain:
1. Try Free Dictionary API
2. If fails → Try Wikipedia API `/page/summary/{title}`
3. If 404 → Show "Definition not found"
- Wikipedia results prefixed with `(Wikipedia)` for source clarity
### Consequences
- ✅ Covers words, phrases, and concepts
- ✅ No additional user friction for common case (single words)
- ✅ User can explicitly choose "Define This Instead" for phrases
- ⚠️ Wikipedia summaries may be longer than dictionary definitions (truncated at 200 chars)
- ⚠️ Need to handle Wikipedia disambiguation pages gracefully
- 📋 Future: Consider caching frequent lookups locally
---
## TDR-002: Lock Screen Notification Strategy
**Date:** 2026-01-18 · **Status:** Superseded by TDR-008 (2026-02-07); MediaStyle revived as a flagged experiment in TDR-010 (2026-08-01) · **Related:** [[Project - Vibe Reader/Build & Iterate/Bug Tracker#BUG-R008|Bug Tracker > BUG-R008]], [[Project - Vibe Reader/Build & Iterate/Feature Backlog#Smart Capture|Feature Backlog > Smart Capture]]
### Context
The core product hypothesis requires capturing words/quotes from the lock screen with minimal friction. Android provides several notification styles with different lock screen behaviors.
### Options Considered
| Option | Description | Pros | Cons |
|--------|-------------|------|------|
| **A. Standard Notification** | Basic notification with action buttons | Reliable; always renders | Small buttons; not prominent on lock screen |
| **B. MediaStyle Notification** | Mimics music player with large controls | Prominent lock screen placement; single-tap capture | Android 13+ aggressively hides "fake" media players; requires metadata |
| **C. Full-Screen Intent** | Like incoming calls; takes over screen | Guaranteed visibility | Too intrusive; user must dismiss |
| **D. Quick Settings Tile** | Custom tile in notification shade | Always accessible | Requires swipe down; 2 taps minimum |
| **E. Accessibility Service** | Intercept gestures/buttons | Zero-tap possible | Play Store policy concerns; user trust issues |
### Decision
**Option B (MediaStyle) as primary, with Option A as fallback**
Implementation approach ("Trojan Horse"):
1. Create `MediaSessionCompat` to register as media app
2. Set metadata (book title as "artist", "Tap ▶ to Capture" as title, app icon as album art)
3. Set `PlaybackState.STATE_PAUSED` so play button (▶) shows
4. Play button action → launches `SpeechCaptureActivity`
5. Fallback: If activity launch fails, show high-priority notification with `fullScreenIntent`
Key learnings:
- Android 13+ requires sufficient metadata or it hides the player
- `FOREGROUND_SERVICE_MEDIA_PLAYBACK` permission required
- Activity launch from background requires `USE_FULL_SCREEN_INTENT` permission
- PendingIntent must target Activity directly (not Service → Activity trampoline)
### Consequences
- ✅ Prominent lock screen placement when it works
- ✅ Single-tap to capture (lowest C2C achievable)
- ⚠️ Inconsistent behavior on some devices/Android versions (see [[Project - Vibe Reader/Build & Iterate/Bug Tracker#BUG-R008|Bug Tracker > BUG-R008]])
- ⚠️ Feels like a "hack" - may break with future Android updates
- 📋 TODO: Consider Option D (Quick Settings Tile) as user-selectable alternative
---
## TDR-001: Relational Database Schema
**Date:** 2026-01-10 · **Status:** Accepted · **Related:** [[Project - Vibe Reader/Build & Iterate/Feature Backlog#Completed|Feature Backlog > Completed]]
### Context
The initial MVP used a flat logging structure where Words and Quotes were stored independently. As the product evolved, we needed to:
1. Associate captures with specific reading sessions (time-based grouping)
2. Associate captures with books (entity-based grouping)
3. Support queries like "show all captures from The Great Gatsby" regardless of session
### Options Considered
| Option | Description | Pros | Cons |
|--------|-------------|------|------|
| **A. Flat Tables** | Words and Quotes as independent tables with session_id | Simple | Cannot query by book without joins; no book-level aggregation |
| **B. Nested JSON** | Store captures as JSON blob per session | Flexible schema | Poor query performance; no relational integrity |
| **C. Relational Hierarchy** | Book → Session → Captures with foreign keys | Normalized; flexible queries; data integrity | More complex schema; requires migrations |
### Decision
**Option C: Relational Hierarchy**
Schema structure:
```
Book (book_id, title, created_at)
└── Session (session_id, book_id, display_name, start_time, end_time, status)
├── Word (word_id, book_id, session_id, term, definition, timestamp)
└── Quote (quote_id, book_id, session_id, content, timestamp)
```
Key insight: Captures are **dual-tagged** to both Session (when) and Book (what). This enables:
- "Show captures from this session" (time-scoped)
- "Show all captures from this book" (entity-scoped)
### Consequences
- ✅ Supports Book Layer view in Archive
- ✅ Enables future features like "book-level stats"
- ✅ Foreign key constraints prevent orphaned data
- ⚠️ Requires `CASCADE` delete rules (deleting a Session deletes its captures)
- ⚠️ Schema changes require migration strategy (using `fallbackToDestructiveMigration` for now)
---
## Pending Decisions
### TDR-005: Offline Support Strategy
**Status:** Not yet decided · **Context:** Dictionary API requires network. What happens when user is offline?
Options under consideration:
- A. Fail gracefully with "No connection" message
- B. Queue words for later definition (store as "pending")
- C. Bundle offline dictionary (large app size)
- D. Cache previous lookups for repeat words
### TDR-006: iOS Port Approach
**Status:** Not yet decided · **Context:** When/if we port to iOS, the lock screen strategy will differ significantly.
Options under consideration:
- A. Live Activities (iOS 16+)
- B. Widget with Siri Shortcuts
- C. Apple Watch companion app
- D. Wait for iOS to offer better lock screen APIs