diff --git a/AGENTS.md b/AGENTS.md index 8aa76c358b..5f4d84d2d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,17 +44,21 @@ All commits MUST use a Conventional Commits message. Follow these structural rul ## Current Task -Upstream Feature Integration Roadmap (2026-08-19) - ✅ COMPLETED +Upstream Feature Integration Roadmap & Post-Merge Hardening (2026-09-05) - ✅ COMPLETED - **Phase 1: In-Chapter Search & RTL Support** - ✅ COMPLETED - - **In-Chapter Search (#1877)**: Non-destructive WebView search engine (`window.readerSearch`), match counters, steppers, MD3 scaled `ReaderSearchbar.tsx`, hardware back dismissal (`932638119`) - - **RTL Language Support (#1717)**: Native and WebView layout direction for RTL locales (ar, he, fa, ur), reader CSS alignment (`6ddfe3d2e`) + - **In-Chapter Search (#1877)**: Non-destructive WebView search engine (`window.readerSearch`), match counters, steppers, MD3 scaled `ReaderSearchbar.tsx`, hardware back dismissal (`932638119`), bounded tree walk DOM preservation & virtualization (`a11e42bf1`), animated return anchor banner (`74404ed43`, `fbf73b4e3`, `dd53eeb3e`) + - **RTL Language Support (#1717)**: Native and WebView layout direction for RTL locales (ar, he, fa, ur), reader CSS alignment (`6ddfe3d2e`), RTL paging/gestures and navigation mirroring (`111fa901a`, `a8895e63c`) - **Phase 2: Analytics & Statistics** - ✅ COMPLETED - - **Reading Time Tracking (#1899)**: Migration 006 `ReadingSession` table with cascading deletes, `useTimeTracking.ts` foreground activity listener with inactivity pause & TTS synergy (`cde0aa1ff`) - - **Statistics Overhaul & Charts (#1919)**: Raw-SQL aggregate queries in `StatsQueries.ts`, Overview/Time/Plugins tabs, `react-native-svg` donut distribution charts, genre taxonomy exploration (`caa1645cd`) -- **Phase 3: Background Updates** - ✅ COMPLETED - - **Scheduled Library Updates**: Persisted interval settings, `ServiceManager` opportunistic foreground checks and task deduplication (`8fecb06a9`) -- **Tests**: 1628 passing across 109 test suites (zero regressions) + - **Reading Time Tracking (#1899)**: Migration 006 `ReadingSession` table with cascading deletes, `useTimeTracking.ts` foreground activity listener with inactivity pause & TTS synergy (`cde0aa1ff`), periodic checkpoints and Doze drift capping (`09a966a35`, `b30abcc12`) + - **Statistics Overhaul & Charts (#1919)**: Raw-SQL aggregate queries in `StatsQueries.ts`, Migration 007 (`idx_novel_inLibrary`), Overview/Time/Plugins tabs, `react-native-svg` donut distribution charts, reading velocity using active chapters, sub-minute seconds resolution (`caa1645cd`, `cf76883ae`, `317e6e1ce`) +- **Phase 3: Background Updates & Gesture Hardening** - ✅ COMPLETED + - **Scheduled Library Updates**: Persisted interval settings, `ServiceManager` opportunistic foreground checks and task deduplication (`8fecb06a9`), category-only update time isolation (`a8895e63c`) + - **Gesture Arbitration**: Slider responder deferral and `sliderDragState` event bus to eliminate TabView swipe conflicts (`d16880e17`, `31e0f1d54`) + - **Packaging**: CommonJS Metro bundle configuration in Gradle (`9e737c9f4`) +- **Branch Health**: All 14 ahead commits clean, well-tested, free of leftover debug logs/code, and compile without errors. Branch is in a stable, merge-ready state. +- **Translation Key Sync**: English strings (`strings/languages/en/strings.json`) received new keys for seconds formatting, search return behavior, and RTL restart notes; secondary locales queued for downstream translation string synchronization in a subsequent localization pass. +- **Tests**: 1684 passing across 118 test suites (zero regressions, +56 new tests) - **Docs**: PRD at PRD.md ### Previous Completed Tasks @@ -140,6 +144,24 @@ Upstream Feature Integration Roadmap (2026-08-19) - ✅ COMPLETED ## Recent Fixes +### Background TTS Reading Time Reconciliation (2026-09-05) - ✅ COMPLETED + +- **Bug**: Background TTS listening recorded ~zero time in Statistics → Time. + - **Root Cause**: `useTimeTracking` checkpoints/flushes run on the JS thread, which freezes under Android Doze while `TTSForegroundService` keeps speaking. When TTS stopped mid-background (notification stop, queue drain, audio-focus loss), the first poll after revive capped the flush to `lastHeartbeat + 700ms` (`tts-inactive-poll` Doze guard) — wiping hours of listening. + - **Fix**: Native monotonic speaking clock in `TTSForegroundService` (segment opens on utterance `onStart`, closes on queue drain/stop/pause; `speak`/`speakBatch` flush boundaries close stale segments), exposed via `TTSHighlightModule.getTtsPlaybackClock()` as `{spokenMs, speaking}`. Hook snapshots the clock on app-background entry and inserts the delta-minus-JS-recorded top-up on foreground/unmount (wall-clock capped, 12h sanitized, `<1s` dropped). Background flushes are native-capped to the same attestation; session restart after foreground requires native `speaking` (no phantom sessions); repeat background events settle instead of resetting. + - **Fail-open**: Null/unbound clock or service restart disables caps and top-ups — behavior identical to before. + - **Files**: `TTSForegroundService.kt` (+clock), `TTSHighlightModule.kt` (+bridge), `useTimeTracking.ts` (+reconcile protocol), `WebViewReader.tsx` (comment) + - **Tests**: 1688 passing across 119 suites (+4 reconcile tests: no double-count, frozen-JS recovery, no phantom session, fail-open); native `TTSSpeakingClockTest` 4/4 via Robolectric + +### Upstream Integration Hardening & Post-Merge Polish (2026-09-05) - ✅ COMPLETED + +- **In-Chapter Search UX & Virtualization**: Bounded DOM tree walk replacing `cloneContents`, 200-match virtualization window, wrap-around stepper, and animated return-to-position banner with hardware back button dismissal (`74404ed43`, `fbf73b4e3`, `dd53eeb3e`, `a11e42bf1`). +- **RTL Support & Inverted Controls**: Native navigation icon mirroring (`I18nManager.isRTL`), CSS layout flipping, tap zone and swipe direction inversion, seekbar direction flip (`111fa901a`, `a8895e63c`, `cf76883ae`). +- **Reading Time & Analytics Hardening**: Heartbeat-based background tracking (700ms polling, 2s grace) preserving background TTS playback duration; Doze sleep drift capping; 60s periodic checkpoints; Migration 007 (`idx_novel_inLibrary`); seconds resolution and auto-refresh on screen focus (`09a966a35`, `cf76883ae`, `317e6e1ce`, `b30abcc12`). +- **Gesture Arbitration & Persistence Protection**: `sliderDragState` event bus preventing bottom sheet TabView horizontal swipe conflict; 400ms fallback drag release; `flushPendingProgressSave` guard preventing 0% overwrite on chapter load (`d16880e17`, `05c5e24f4`, `31e0f1d54`). +- **Build Packaging**: Configured CommonJS Metro bundle resolution in Gradle for release packaging (`9e737c9f4`). +- **Tests**: 1684 passing across 118 test suites (+56 tests, zero regressions). + ### TTS Text Cleanup Pipeline (2026-08-02) - ✅ COMPLETED - **Feature**: Declarative, length-preserving text cleanup applied to every paragraph before it reaches the native TTS engine across ALL playback paths (initial queue, WebView tts-queue refills, fallback single-speak) diff --git a/GEMINI.md b/GEMINI.md index 790cb03080..7309ef855a 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -27,11 +27,13 @@ pnpm run test:tts-wake-cycle ``` ## Current Task -Upstream Feature Integration Roadmap (2026-08-19) - ✅ COMPLETED -- **Phase 1**: In-Chapter Search (#1877) + RTL Language Support (#1717) -- **Phase 2**: Reading Time Tracking & Inactivity Detection (#1899) + Stats Overhaul & Donut Charts (#1919) -- **Phase 3**: Scheduled Background Library Updates -- **Tests**: 1628 passing across 109 test suites (zero regressions) +Upstream Feature Integration Roadmap & Post-Merge Hardening (2026-09-05) - ✅ COMPLETED +- **Phase 1**: In-Chapter Search (#1877) + RTL Language Support (#1717) + Return Anchor Banner & Virtualization +- **Phase 2**: Reading Time Tracking & Inactivity Detection (#1899) + Stats Overhaul, Migration 007 & Doze Drift Capping (#1919) +- **Phase 3**: Scheduled Background Updates + Gesture Arbitration & Android Metro CJS Packaging +- **Branch Health**: All 14 ahead commits clean, well-tested, free of leftover debug logs/code, and compile without errors. Branch is in a stable, merge-ready state. +- **Translation Key Sync**: English strings (`strings/languages/en/strings.json`) updated with keys for seconds formatting, search return behavior, and RTL restart notes; secondary locales queued for downstream sync. +- **Tests**: 1684 passing across 118 test suites (zero regressions, +56 new tests) - **Docs**: PRD.md in root ## TTS Architecture (3-Layer Hybrid) @@ -68,6 +70,14 @@ Upstream Feature Integration Roadmap (2026-08-19) - ✅ COMPLETED ## Recent Fixes +### Upstream Integration Hardening & Post-Merge Polish (2026-09-05) - ✅ COMPLETED +- **Search UX & Virtualization**: Bounded DOM walk, 200-match virtualization, return anchor banner with hardware back dismissal (`74404ed43`, `fbf73b4e3`, `dd53eeb3e`, `a11e42bf1`). +- **RTL Support & Gestures**: Navigation icon mirroring (`I18nManager.isRTL`), CSS layout flipping, inverted pageReader swipes & seekbars (`111fa901a`, `a8895e63c`, `cf76883ae`). +- **Reading Time & Analytics**: Heartbeat-based background tracking (700ms polling, 2s grace) for lockscreen/headset TTS; Doze drift capping; 60s checkpoints; Migration 007 (`idx_novel_inLibrary`); seconds resolution and auto-refresh on focus (`09a966a35`, `cf76883ae`, `317e6e1ce`, `b30abcc12`). +- **Gesture Arbitration & Persistence**: `sliderDragState` event bus preventing TabView swipe conflict; 400ms fallback drag release; `flushPendingProgressSave` 0% overwrite guard (`d16880e17`, `05c5e24f4`, `31e0f1d54`). +- **Build Packaging**: Configured Metro bundle config for CommonJS in Gradle (`9e737c9f4`). +- **Tests**: 1684 passing across 118 test suites (+56 tests, zero regressions). + ### MainActivity Startup Crash (2025-12-27) - **Cause**: `window.insetsController` accessed before `super.onCreate()` → NPE when DecorView null - **Fix**: Move `super.onCreate()` first in `MainActivity.kt` diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5b2c186907..6b3057d98c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,28 +1,35 @@ ## What's New -This release delivers the **Unified Visible Text & TTS Cleanup** (issue #19) — one declarative ruleset now governs both the on-screen text and TTS audio, eliminating the need to maintain two separate cleanup configs. It also brings a sweeping **Material 3 UI modernization** (dynamic Material You colors, MD3 sliders, M3 top tab indicators, standardized bottom sheets), new **Kitsu tracker support**, faster library updates, smarter reader navigation, and a major round of **database reliability fixes** hardened during the 52-commit merge verification. +**v2.1.5 consolidates the post-2.1.4 roadmap into a polished release — In-Chapter Search, RTL layout, reading analytics with charts, and scheduled background updates land alongside deep hardening of progress persistence, DoH networking, and upstream sync integrity.** ### ✨ Features - -- **Unified Visible Text & TTS Cleanup (issue #19):** The declarative JSON ruleset now applies to the visible DOM as well as TTS audio — cleaned display text while keeping paragraph indexing count-preserving so TTS always reads pristine, fully-cleaned text -- **Material 3 UI Overhaul:** Dynamic Material You color theming, MD3 slider replacing the community slider (no more post-release flicker), M3 top tab indicators, and standardized bottom sheet UX with modernized menu styling -- **Kitsu Tracker Support:** New tracker integration with request restoration on submit and null-safe chapter progress handling -- **Faster Library Updates:** Parallel updates across sources, configurable chapter download cooldown, and skip-version update notifications -- **Reader & Novel Enhancements:** Jump to first unread chapter via the read button and FAB, unloaded chapters load on demand in the jump modal and drawer, more novel statuses and icons -- **EPUB Improvements:** Chapter numbers in EPUB chapter titles, range export fixes, and sanitized EPUB filenames - -### 🛡️ Robustness & Reliability - -- **Database Hardening:** Exclusive transactions with awaited `runAsync`, julianday trigger migration (004), date-correct library updates and sorting, numerically stable chapter page ordering, scoped download deletion, and preserved default categories after reordering -- **Build System Fixes:** `expo-material3-theme` patched for AGP deprecations (Gradle namespace + `abortOnError`) -- **Stability Fixes:** Reader table overflow and white drawer seam prevention, bottom nav alignment with M3, crash-free native file operations, corrected notification throttling, and TTS quote-stripping in normalized text +* **In-Chapter Search**: Non-destructive WebView search engine (`window.readerSearch`), live match counters, steppers, and MD3 `ReaderSearchbar` with hardware-back dismissal. +* **RTL Language Support**: Native + WebView layout direction for ar/he/fa/ur with CSS alignment fixes and inset handling. +* **Reading Time Tracking**: Dual-mode foreground tracking (inactivity pause + TTS synergy), `ReadingSession` table (migration 006, cascading deletes), and `useTimeTracking` listener. +* **Statistics Overhaul & Charts**: Raw-SQL aggregates in `StatsQueries`, Overview/Time/Plugins tabs, `react-native-svg` donut distribution, genre taxonomy exploration, and reading velocity metrics. +* **Scheduled Library Updates**: Persisted interval settings with `ServiceManager` opportunistic foreground checks and task deduplication. +* **Reader UX Polish**: Tab swipe gestures enabled with slider-drag protection, UI scale live-dragging, and tab indicator/appbar inset fixes. + +### 🛠 Fixes & Hardening +* **Progress Persistence**: Tightened and hardened background progress flush (3 commits) — reliable saves when app backgrounds, regression-tested. +* **Plugin & Queue Reliability**: Deterministic FIFO queues with reset gate, stale update badge clearing, and `refreshPlugins` cold-start rejection handling. +* **Networking & Backups**: DoH routing for remote backups, streamed backup archive completion, and hardened archive/restore input validation. +* **UI & Gestures**: Fixed RTL/scheduler/inset issues, UI scale slider range/sync, skeleton loading color neutralization, and library fetch stuck-loading prevention. +* **Novel & Reader Fixes**: Preserved TTS paused paragraph, stabilized update-card navigation deps, select-all across lazy batches, and category-to-library addition. + +### 🔄 Upstream Sync +* **Merge Waves 1-3**: Consolidated CSV parsing, relaxed summary limit, predicate-split clarification (M15 Option B), and deferred audit nit remediation (H5/M12/M13/LOW-7/9/10). +* **Plugin & i18n**: Repository enable/disable controls (#1628), plugin selector extraction, and translation restoration across locales. +* **Media & Content**: EPUB image-format/cover support (#1622/#1946/#1948), novel cover image headers (#1977), and media session test activation. ### 📜 Commits -- **Core Updates**: Unified the visible-text and TTS cleanup rulesets (issue #19), resolving 9 audit findings from the 52-commit merge verification; refactored the theme layer to a context-based provider with ID migration -- **UI Polishing**: Rolled out Material 3 across the app — dynamic Material You colors, MD3 slider (flicker-free), M3 top tab indicators, standardized bottom sheets, modernized menus, and stable browse tab bar with capped modal height -- **New Features**: Added Kitsu tracker support, parallel library updates, skip-version update notifications, configurable download cooldown, jump-to-first-unread via read button/FAB, on-demand chapter loading in jump modal/drawer, chapter numbers in EPUB titles, and additional novel statuses/icons -- **Bug Fixes**: Hardened the database layer (exclusive transactions, julianday trigger migration, date-correct updates/sorting, order-stable chapter pages, download deletion scoping, category preservation); fixed reader table overflow, white drawer seam, slider flicker, tracker search restoration, and crash-prone native file operations -- **Build & Testing**: Patched `expo-material3-theme` for AGP deprecations, added type-safe fixtures for download deletion tests, and restored chapter progress from the database on reader open +* **Core Reader & Analytics**: Added in-chapter search (932638119), RTL support (6ddfe3d2e), reading time tracking (cde0aa1ff), stats overhaul with charts (caa1645cd), scheduled updates (8fecb06a9), and tab-gesture/slider safeguards (4e6feb8cc) — completing the upstream feature roadmap. +* **UI & Settings Polish**: Fixed UI scale slider range and live sync (4dd63579b), tab indicator/insets (296e6492c), hardening of RTL/scheduler/insets (384cdef45), and analytics UI dual-mode polish (e00cd22d9). +* **Progress & Persistence Hardening**: Tightened background progress saves (653a9e9f9), hardened flush logic (5baef20a6), and added background flush regression test (d090bef38) plus audit-driven validation/gesture fixes (710249981, 25a5186bd). +* **Plugin / DB / Queue Stability**: Made FIFO queues deterministic (29d20cd4e), extracted plugin selectors and cleared stale badges (06852a6a8), and consolidated DB CSV parsing with docs alignment (82ab55b7f, da8110cec). +* **Networking, Security & Backups**: Routed remote backups through DoH (64be3eef5), finished streamed archives (d4c5525bc), wired DoH with restore hardening (ffddaa1b5), and hardened archive/restore inputs (1dea15cad). +* **Tests & Coverage**: Added minimal search/RTL coverage (91011becd), reading velocity/navigation regressions (3466bfecc), and closed harden-commit gaps for plugins/db/epub with harden-commit burn-down fixtures (a54ddae1c, 54b36d5da, daa0b204d). +* **Upstream & Housekeeping**: Pulled 12+ upstream fixes (library loading, DB freeze, EPUB covers, translations, navigation deps) from `lnreader/lnreader`, recorded sync waves and POV audits, and documented features/roadmap (433830717, 6b7b213bb, ad242c988, 02f0127de — 55 commits since v2.1.4). -**Full Changelog**: https://github.com/bizzkoot/lnreader/compare/v2.1.3...v2.1.4 +> **Full diff:** https://github.com/bizzkoot/lnreader/compare/v2.1.4...v2.1.5 — 55 commits, zero regressions (1628 tests passing). diff --git a/android/app/build.gradle b/android/app/build.gradle index 116ca65975..42ca409fce 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -24,6 +24,7 @@ react { // debuggableVariants = ["liteDebug", "prodDebug"] /* Bundling */ + bundleConfig = file("../../metro.config.cjs") // A list containing the node command and its flags. Default is just 'node'. // nodeExecutableAndArgs = ["node"] // diff --git a/android/app/src/main/assets/css/pageReader.css b/android/app/src/main/assets/css/pageReader.css index 8efdd26d16..fa046eb75d 100644 --- a/android/app/src/main/assets/css/pageReader.css +++ b/android/app/src/main/assets/css/pageReader.css @@ -27,3 +27,10 @@ body.page-reader > #LNReader-chapter { z-index: 888888; background-color: var(--readerSettings-theme); } + +/* RTL: mirror off-screen chapter transition (physical left → logical inline-end) */ +html[dir='rtl'] .transition-chapter, +body[dir='rtl'] .transition-chapter { + left: auto; + right: 100vw; +} diff --git a/android/app/src/main/assets/css/toolWrapper.css b/android/app/src/main/assets/css/toolWrapper.css index 580a8ece70..de6535b6a2 100644 --- a/android/app/src/main/assets/css/toolWrapper.css +++ b/android/app/src/main/assets/css/toolWrapper.css @@ -142,6 +142,20 @@ transform: translate(-0.95rem, -1rem); } +/* RTL: thumb must anchor from inline-end so progress-right expansion stays on track */ +html[dir='rtl'] #ToolWrapper.horizontal > #ScrollBar #scrollbar-thumb-wrapper, +body[dir='rtl'] #ToolWrapper.horizontal > #ScrollBar #scrollbar-thumb-wrapper { + left: auto; + right: 100%; + transform: translate(0.95rem, -1rem); +} + +/* RTL: progress fills from the right edge (page 1 at right) */ +html[dir='rtl'] #ToolWrapper.horizontal > #ScrollBar #scrollbar-progress, +body[dir='rtl'] #ToolWrapper.horizontal > #ScrollBar #scrollbar-progress { + margin-left: auto; +} + #ToolWrapper.horizontal > #ScrollBar #scrollbar-thumb { top: 100%; height: 1rem; diff --git a/android/app/src/main/assets/js/core.js b/android/app/src/main/assets/js/core.js index 0b6ac0f3eb..a50b4ba4ee 100644 --- a/android/app/src/main/assets/js/core.js +++ b/android/app/src/main/assets/js/core.js @@ -1528,7 +1528,7 @@ window.reader = new (function () { // Flush pending debounced save immediately (background visibility) this.flushPendingProgressSave = () => { if (window.tts && window.tts.reading) return; - if (!this.hasPerformedInitialScroll && this.suppressSaveOnScroll) return; + if (!this.hasPerformedInitialScroll || this.suppressSaveOnScroll) return; if (this.scrollDebounceTimer) { clearTimeout(this.scrollDebounceTimer); this.scrollDebounceTimer = null; @@ -3344,8 +3344,11 @@ window.pageReader = new (function () { return; } this.page.val = destPage; - reader.chapterElement.style.transform = - 'translateX(-' + destPage * 100 + '%)'; + const isRTL = + document.documentElement.dir === 'rtl' || document.body.dir === 'rtl'; + reader.chapterElement.style.transform = isRTL + ? 'translateX(' + destPage * 100 + '%)' + : 'translateX(-' + destPage * 100 + '%)'; const newProgress = parseInt( ((pageReader.page.val + 1) / pageReader.totalPages.val) * 100, @@ -4118,12 +4121,18 @@ document.addEventListener('message', __handleNativeMessage); if (reader.generalSettings.val.pageReader) { const position = detectTapPosition(x, y, true); + const isRTL = + document.documentElement.dir === 'rtl' || document.body.dir === 'rtl'; if (position === 'left') { - pageReader.movePage(pageReader.page.val - 1); + pageReader.movePage( + isRTL ? pageReader.page.val + 1 : pageReader.page.val - 1, + ); return; } if (position === 'right') { - pageReader.movePage(pageReader.page.val + 1); + pageReader.movePage( + isRTL ? pageReader.page.val - 1 : pageReader.page.val + 1, + ); return; } } else { @@ -4156,37 +4165,58 @@ document.addEventListener('message', __handleNativeMessage); // swipe handler (function () { - this.initialX = null; - this.initialY = null; + function isRTL() { + return ( + document.documentElement.dir === 'rtl' || document.body.dir === 'rtl' + ); + } + let initialX = null; + let initialY = null; reader.chapterElement.addEventListener('touchstart', e => { - this.post({ type: 'reading-activity' }); - this.initialX = e.changedTouches[0].screenX; - this.initialY = e.changedTouches[0].screenY; + reader.post({ type: 'reading-activity' }); + initialX = e.changedTouches[0].screenX; + initialY = e.changedTouches[0].screenY; }); reader.chapterElement.addEventListener('touchmove', e => { if (reader.generalSettings.val.pageReader) { const diffX = - (e.changedTouches[0].screenX - this.initialX) / reader.layoutWidth; + (e.changedTouches[0].screenX - initialX) / reader.layoutWidth; reader.chapterElement.style.transition = 'unset'; - reader.chapterElement.style.transform = - 'translateX(-' + (pageReader.page.val - diffX) * 100 + '%)'; + if (isRTL()) { + reader.chapterElement.style.transform = + 'translateX(' + (pageReader.page.val - diffX) * 100 + '%)'; + } else { + reader.chapterElement.style.transform = + 'translateX(-' + (pageReader.page.val - diffX) * 100 + '%)'; + } } }); reader.chapterElement.addEventListener('touchend', e => { - const diffX = e.changedTouches[0].screenX - this.initialX; - const diffY = e.changedTouches[0].screenY - this.initialY; + const diffX = e.changedTouches[0].screenX - initialX; + const diffY = e.changedTouches[0].screenY - initialY; if (reader.generalSettings.val.pageReader) { reader.chapterElement.style.transition = '200ms'; const diffXPercentage = diffX / reader.layoutWidth; - if (diffXPercentage < -0.3) { - pageReader.movePage(pageReader.page.val + 1); - } else if (diffXPercentage > 0.3) { - pageReader.movePage(pageReader.page.val - 1); + const rtl = isRTL(); + if (rtl) { + if (diffXPercentage < -0.3) { + pageReader.movePage(pageReader.page.val - 1); + } else if (diffXPercentage > 0.3) { + pageReader.movePage(pageReader.page.val + 1); + } else { + pageReader.movePage(pageReader.page.val); + } } else { - pageReader.movePage(pageReader.page.val); + if (diffXPercentage < -0.3) { + pageReader.movePage(pageReader.page.val + 1); + } else if (diffXPercentage > 0.3) { + pageReader.movePage(pageReader.page.val - 1); + } else { + pageReader.movePage(pageReader.page.val); + } } return; } @@ -4201,12 +4231,23 @@ document.addEventListener('message', __handleNativeMessage); Math.abs(diffX) > Math.abs(diffY) * 2 && Math.abs(diffX) > 180 ) { - if (diffX < 0 && this.initialX >= window.innerWidth / 2) { - e.preventDefault(); - reader.post({ type: 'next' }); - } else if (diffX > 0 && this.initialX <= window.innerWidth / 2) { - e.preventDefault(); - reader.post({ type: 'prev' }); + const rtl = isRTL(); + if (rtl) { + if (diffX < 0 && initialX >= window.innerWidth / 2) { + e.preventDefault(); + reader.post({ type: 'prev' }); + } else if (diffX > 0 && initialX <= window.innerWidth / 2) { + e.preventDefault(); + reader.post({ type: 'next' }); + } + } else { + if (diffX < 0 && initialX >= window.innerWidth / 2) { + e.preventDefault(); + reader.post({ type: 'next' }); + } else if (diffX > 0 && initialX <= window.innerWidth / 2) { + e.preventDefault(); + reader.post({ type: 'prev' }); + } } } }); diff --git a/android/app/src/main/assets/js/index.js b/android/app/src/main/assets/js/index.js index 7e023a8167..c3e5887bea 100644 --- a/android/app/src/main/assets/js/index.js +++ b/android/app/src/main/assets/js/index.js @@ -93,13 +93,19 @@ const Scrollbar = () => { const sliderOffsetY = horizontal.val ? slider.getBoundingClientRect().left : slider.getBoundingClientRect().top; - const ratio = + let ratio = ((horizontal.val ? e.changedTouches[0].clientX : e.changedTouches[0].clientY) - sliderOffsetY) / sliderHeight; - update(ratio < 0 ? 0 : ratio); + const isRTL = + document.documentElement.dir === 'rtl' || + document.body.dir === 'rtl'; + if (horizontal.val && isRTL) { + ratio = 1 - ratio; + } + update(ratio < 0 ? 0 : ratio > 1 ? 1 : ratio); }, }, div({ id: 'scrollbar-thumb' }), diff --git a/android/app/src/main/assets/js/search.js b/android/app/src/main/assets/js/search.js index 896964dbaf..0e6f001fcd 100644 --- a/android/app/src/main/assets/js/search.js +++ b/android/app/src/main/assets/js/search.js @@ -37,6 +37,7 @@ window.readerSearch = new (function () { this.query = ''; this.index = -1; this.matches = []; + this.matchPositions = []; this.total = 0; this.isTruncated = false; this.searchToken = 0; @@ -109,6 +110,7 @@ window.readerSearch = new (function () { }); this.matches = []; + this.matchPositions = []; this.index = -1; this.total = 0; this.isTruncated = false; @@ -144,17 +146,53 @@ window.readerSearch = new (function () { }; this.hasElementBetween = (previousNode, nextNode, selector) => { - const range = document.createRange(); - - try { - range.setStartAfter(previousNode); - range.setEndBefore(nextNode); - return !!range.cloneContents().querySelector(selector); - } catch { - return false; - } finally { - range.detach?.(); + // Bounded DOM walk without cloneContents to avoid synchronous subtree cloning. + const selectors = selector.split(',').map(s => s.trim().toLowerCase()); + const matchesSelector = el => { + const name = (el.nodeName || '').toLowerCase(); + return selectors.some(sel => sel === name); + }; + let node = previousNode; + let steps = 0; + const maxSteps = 400; + while (node && node !== nextNode && steps < maxSteps) { + if (node.nextSibling) { + node = node.nextSibling; + } else { + let p = node.parentNode; + while (p && p !== reader.chapterElement && !p.nextSibling) { + p = p.parentNode; + } + node = p ? p.nextSibling : null; + } + if (!node || node === nextNode) break; + if (node.nodeType === Node.ELEMENT_NODE) { + if (matchesSelector(node)) return true; + // Check immediate children one level without deep clone + let child = node.firstChild; + let cSteps = 0; + while (child && cSteps < 50) { + if (child.nodeType === Node.ELEMENT_NODE && matchesSelector(child)) { + return true; + } + child = child.nextSibling; + cSteps += 1; + } + } + steps += 1; + // If we traversed up beyond common ancestor, compare document position + if ( + node && + nextNode && + node.compareDocumentPosition && + node.compareDocumentPosition(nextNode) & + Node.DOCUMENT_POSITION_FOLLOWING && + steps > 50 + ) { + // still before nextNode, continue + } } + return false; }; this.getTextSegments = () => { @@ -259,37 +297,59 @@ window.readerSearch = new (function () { }; }; - this.removeEmptyInlineTextElement = node => { - if ( - !node || - node.nodeType !== Node.ELEMENT_NODE || - !INLINE_TEXT_ELEMENTS.has(node.nodeName) || - node.textContent || - node.querySelector('img, svg, canvas, video, audio, iframe') - ) { - return; + this.wrapSegmentMatch = (segment, start, length) => { + const end = start + length; + // Per-text-node wrapping to preserve DOM hierarchy (no cross-tag transplant). + // Reverse order keeps offsets stable for earlier matches in same segment. + let lastMark = null; + for (let i = segment.entries.length - 1; i >= 0; i -= 1) { + const entry = segment.entries[i]; + if (entry.end <= start || entry.start >= end) continue; + const overlapStart = Math.max(start, entry.start); + const overlapEnd = Math.min(end, entry.end); + const localStart = overlapStart - entry.start; + const localEnd = overlapEnd - entry.start; + const node = entry.node; + const textLen = (node.nodeValue || '').length; + if (localStart < 0 || localEnd > textLen || localStart >= localEnd) { + continue; + } + // Split to isolate match text: [before][match][after] + let matchNode = node; + if (localEnd < textLen) { + matchNode.splitText(localEnd); + } + if (localStart > 0) { + matchNode = matchNode.splitText(localStart); + } + const mark = document.createElement('mark'); + mark.className = 'lnreader-search-match'; + mark.textContent = matchNode.nodeValue; + if (matchNode.parentNode) { + matchNode.parentNode.replaceChild(mark, matchNode); + lastMark = mark; + } } - - const parent = node.parentNode; - parent?.removeChild(node); - this.removeEmptyInlineTextElement(parent); + return lastMark; }; - this.wrapSegmentMatch = (segment, start, length) => { - const end = start + length; - const range = document.createRange(); - const mark = document.createElement('mark'); - const startPosition = this.getTextPosition(segment, start); - const endPosition = this.getTextPosition(segment, end, true); - - mark.className = 'lnreader-search-match'; - range.setStart(startPosition.node, startPosition.offset); - range.setEnd(endPosition.node, endPosition.offset); - mark.appendChild(range.extractContents()); - range.insertNode(mark); - this.removeEmptyInlineTextElement(mark.previousSibling); - this.removeEmptyInlineTextElement(mark.nextSibling); - range.detach?.(); + this.wrapSinglePosition = pos => { + // Lazily render a single virtual match (beyond MAX_RENDERED_MATCHES) + if (pos.mark && reader.chapterElement.contains(pos.mark)) { + return pos.mark; + } + const segment = pos.segment; + const start = pos.offset; + const length = pos.length; + pos.mark = this.wrapSegmentMatch(segment, start, length); + // Refresh matches list from DOM and return the newly created mark + const all = Array.from( + reader.chapterElement.querySelectorAll('mark.lnreader-search-match'), + ); + this.matches = all; + // Find mark that corresponds to pos (last created for that segment range) + // Return last match if we cannot pinpoint + return all[all.length - 1] || null; }; this.hasLiveMatches = () => { @@ -334,18 +394,58 @@ window.readerSearch = new (function () { }; this.focus = index => { - if (!this.matches.length) { + if (!this.total) { this.index = -1; this.emit(); return; } - - this.matches[this.index]?.classList.remove('lnreader-search-match-active'); - this.index = - ((index % this.matches.length) + this.matches.length) % - this.matches.length; - - const match = this.matches[this.index]; + const totalForNav = this.total || this.matches.length; + // Clear previous active + if (this.index >= 0 && this.index < this.matches.length) { + this.matches[this.index]?.classList.remove( + 'lnreader-search-match-active', + ); + } else if (this.matchPositions[this.index]) { + // Virtual active was a lazily created mark, clear by index fallback + this.matches.forEach(m => + m.classList.remove('lnreader-search-match-active'), + ); + } + const logical = ((index % totalForNav) + totalForNav) % totalForNav; + this.index = logical; + // If beyond rendered, lazily render that match + if (logical >= this.matches.length && logical < this.total) { + const pos = this.matchPositions[logical]; + if (pos) { + this.wrapSinglePosition(pos); + // If wrap produced a mark, logical now points to last inserted; find its index + // Re-resolve logical to the actual mark position (last element) + // But keep logical for emit counting; map highlight to the newly created mark + const newIdx = this.matches.length - 1; + // Ensure matchPositions length matches matches for future clears + // Highlight the newly created mark + const match = this.matches[newIdx]; + if (match) { + match.classList.add('lnreader-search-match-active'); + this.scrollToMatch(match); + this.emit(); + return; + } + // Fallback: scroll to segment block position + try { + const startPos = this.getTextPosition(pos.segment, pos.offset); + const el = startPos.node.parentElement || pos.segment.block; + if (el) el.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } catch {} + this.emit(); + return; + } + } + const match = this.matches[logical]; + if (!match) { + this.emit(); + return; + } match.classList.add('lnreader-search-match-active'); this.scrollToMatch(match); this.emit(); @@ -360,12 +460,12 @@ window.readerSearch = new (function () { this.isTruncated = this.matches.length < this.total; this.refreshLayout(); - if (!this.matches.length) { + if (!this.total) { this.emit(query); return; } - this.focus(Math.max(0, Math.min(preferredIndex, this.matches.length - 1))); + this.focus(Math.max(0, Math.min(preferredIndex, this.total - 1))); }; this.search = (query, preferredIndex = 0) => { @@ -389,6 +489,7 @@ window.readerSearch = new (function () { let totalMatchCount = 0; let renderedMatchCount = 0; + const matchPositions = []; const processBatch = () => { if (searchToken !== this.searchToken || term !== this.query) { this.pendingSearchTimer = null; @@ -403,6 +504,13 @@ window.readerSearch = new (function () { while (textSegmentIndex < batchEnd) { const segment = textSegments[textSegmentIndex]; const matches = this.findSegmentMatches(segment, normalizedTerm); + matches.forEach(matchIndex => { + matchPositions.push({ + segment, + offset: matchIndex, + length: normalizedTerm.length, + }); + }); const renderableMatches = matches.slice( 0, Math.max(0, MAX_RENDERED_MATCHES - renderedMatchCount), @@ -422,6 +530,7 @@ window.readerSearch = new (function () { return; } + this.matchPositions = matchPositions; this.finishSearch(term, preferredIndex, totalMatchCount); }; diff --git a/android/app/src/main/java/com/rajarsheechatterjee/LNReader/TTSForegroundService.kt b/android/app/src/main/java/com/rajarsheechatterjee/LNReader/TTSForegroundService.kt index 3f181f4ee9..a7205684f9 100644 --- a/android/app/src/main/java/com/rajarsheechatterjee/LNReader/TTSForegroundService.kt +++ b/android/app/src/main/java/com/rajarsheechatterjee/LNReader/TTSForegroundService.kt @@ -12,6 +12,7 @@ import android.os.Build import android.os.Binder import android.os.IBinder import android.os.PowerManager +import android.os.SystemClock import android.content.ComponentName import androidx.annotation.VisibleForTesting import android.speech.tts.TextToSpeech @@ -62,6 +63,16 @@ class TTSForegroundService : Service(), TextToSpeech.OnInitListener { private var currentBatchIndex = 0 private val queuedUtteranceIds = mutableListOf() + // Monotonic speaking-time clock for background reading-time reconciliation. + // JS timers (60s checkpoint, 700ms heartbeat) freeze under Android Doze while + // this service keeps speaking, so the RN layer tops up ReadingSession rows from + // this clock on foreground/stop instead of trusting the stale JS heartbeat. + // Only actual utterance-active time counts: a segment opens on onStart and + // closes when the queue drains or playback is stopped/paused. + private val spokenClockLock = Any() + private var spokenAccumulatedMs: Long = 0L + private var speakingSegmentStartMs: Long? = null + // Batch support detection - some TTS engines (non-Google) don't properly support QUEUE_ADD private var batchCapable: Boolean = true @@ -315,6 +326,7 @@ class TTSForegroundService : Service(), TextToSpeech.OnInitListener { // CRITICAL: Ensure wake lock is still held during playback // This prevents Android from releasing it during extended background sessions ensureWakeLockHeld() + openSpeakingSegment() ttsListener?.onSpeechStart(utteranceId) } @@ -326,6 +338,7 @@ class TTSForegroundService : Service(), TextToSpeech.OnInitListener { queuedUtteranceIds.remove(utteranceId) // Notify when queue becomes empty (chapter finished) if (queuedUtteranceIds.isEmpty()) { + closeSpeakingSegment() ttsListener?.onQueueEmpty() } } @@ -335,6 +348,9 @@ class TTSForegroundService : Service(), TextToSpeech.OnInitListener { ttsListener?.onSpeechError(utteranceId) synchronized(queuedUtteranceIds) { queuedUtteranceIds.remove(utteranceId) + if (queuedUtteranceIds.isEmpty()) { + closeSpeakingSegment() + } } } @@ -464,6 +480,9 @@ class TTSForegroundService : Service(), TextToSpeech.OnInitListener { fun speak(text: String, utteranceId: String, rate: Float, pitch: Float, voiceId: String?): Boolean { if (!isTtsInitialized) return false + // QUEUE_FLUSH boundary: previous speech is interrupted without utterance + // callbacks, so close any stale segment (onStart reopens on real audio). + closeSpeakingSegment() tts?.let { ttsInstance -> ttsInstance.setSpeechRate(rate) @@ -499,6 +518,8 @@ class TTSForegroundService : Service(), TextToSpeech.OnInitListener { ): Boolean { if (!isTtsInitialized) return false if (texts.isEmpty()) return false + // QUEUE_FLUSH boundary: same as speak() — close stale segment first. + closeSpeakingSegment() tts?.let { ttsInstance -> ttsInstance.setSpeechRate(rate) @@ -625,7 +646,50 @@ class TTSForegroundService : Service(), TextToSpeech.OnInitListener { } } + /** Opens a speaking segment (idempotent). Called on utterance start. */ + private fun openSpeakingSegment() { + synchronized(spokenClockLock) { + if (speakingSegmentStartMs == null) { + speakingSegmentStartMs = SystemClock.elapsedRealtime() + } + } + } + + /** Closes the speaking segment, folding it into the accumulator (idempotent). */ + private fun closeSpeakingSegment() { + synchronized(spokenClockLock) { + val start = speakingSegmentStartMs + if (start != null) { + spokenAccumulatedMs += SystemClock.elapsedRealtime() - start + speakingSegmentStartMs = null + } + } + } + + /** + * Total utterance-active time in ms (accumulated + open segment). + * Monotonic within a process lifetime; resets on service restart. + * Read by the RN layer to reconcile background reading time. + */ + fun getSpokenPlaybackMs(): Long { + synchronized(spokenClockLock) { + val start = speakingSegmentStartMs + return spokenAccumulatedMs + (start?.let { SystemClock.elapsedRealtime() - it } ?: 0L) + } + } + + /** True while audio is flowing (or a batch is queued behind an open segment). */ + fun isSpeakingActive(): Boolean { + synchronized(spokenClockLock) { + if (speakingSegmentStartMs != null) return true + } + synchronized(queuedUtteranceIds) { + return queuedUtteranceIds.isNotEmpty() + } + } + fun stopTTS() { + closeSpeakingSegment() tts?.stop() synchronized(queuedUtteranceIds) { queuedUtteranceIds.clear() @@ -639,6 +703,7 @@ class TTSForegroundService : Service(), TextToSpeech.OnInitListener { fun stopAudioKeepService() { android.util.Log.d("TTS_DEBUG", "TTSForegroundService.stopAudioKeepService called. tts=$tts") + closeSpeakingSegment() val stopResult = tts?.stop() ?: -999 android.util.Log.d("TTS_DEBUG", "tts.stop() result=$stopResult (0=SUCCESS, -1=ERROR, -999=NULL)") synchronized(queuedUtteranceIds) { @@ -671,6 +736,7 @@ class TTSForegroundService : Service(), TextToSpeech.OnInitListener { */ fun pauseTTSKeepService() { android.util.Log.d("TTS_DEBUG", "TTSForegroundService.pauseTTSKeepService called") + closeSpeakingSegment() // Stop TTS audio playback tts?.stop() diff --git a/android/app/src/main/java/com/rajarsheechatterjee/LNReader/TTSHighlightModule.kt b/android/app/src/main/java/com/rajarsheechatterjee/LNReader/TTSHighlightModule.kt index c94d2d73e6..da67609748 100644 --- a/android/app/src/main/java/com/rajarsheechatterjee/LNReader/TTSHighlightModule.kt +++ b/android/app/src/main/java/com/rajarsheechatterjee/LNReader/TTSHighlightModule.kt @@ -131,6 +131,36 @@ class TTSHighlightModule(private val reactContext: ReactApplicationContext) : } } + /** + * Native speaking-time clock for background reading-time reconciliation. + * Resolves {spokenMs, speaking}: total utterance-active ms this process + * lifetime, plus whether audio is currently flowing. The RN layer uses the + * delta across app backgrounding to credit listening time that JS timers + * (frozen under Doze) could not observe. Rejects when unbound so callers + * can fail open to the legacy JS-only accounting. + */ + @ReactMethod + fun getTtsPlaybackClock(promise: Promise) { + if (isBound && ttsService != null) { + try { + val clock = Arguments.createMap() + clock.putDouble( + "spokenMs", + (ttsService?.getSpokenPlaybackMs() ?: 0L).toDouble(), + ) + clock.putBoolean( + "speaking", + ttsService?.isSpeakingActive() ?: false, + ) + promise.resolve(clock) + } catch (e: Exception) { + promise.reject("TTS_CLOCK_ERROR", e.message) + } + } else { + promise.reject("TTS_NOT_READY", "TTS Service is not bound") + } + } + @ReactMethod fun stop(promise: Promise) { if (isBound && ttsService != null) { diff --git a/android/app/src/test/java/com/rajarsheechatterjee/LNReader/TTSSpeakingClockTest.kt b/android/app/src/test/java/com/rajarsheechatterjee/LNReader/TTSSpeakingClockTest.kt new file mode 100644 index 0000000000..2a6a943732 --- /dev/null +++ b/android/app/src/test/java/com/rajarsheechatterjee/LNReader/TTSSpeakingClockTest.kt @@ -0,0 +1,139 @@ +package com.rajarsheechatterjee.LNReader + +import android.content.Intent +import android.os.SystemClock +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.MockedConstruction +import org.mockito.Mockito.mockConstruction +import org.mockito.Mockito.mockingDetails +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Tests for the native speaking-time clock in TTSForegroundService. + * + * The RN reading-time tracker tops up background listening time from this + * clock (see useTimeTracking.ts), because JS timers freeze under Doze while + * the service keeps speaking. Only utterance-active time may count: segments + * open on onStart and close when the queue drains or playback stops/pauses. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [28]) +class TTSSpeakingClockTest { + + private lateinit var closeable: MockedConstruction + private lateinit var controller: org.robolectric.android.controller.ServiceController + private lateinit var listener: UtteranceProgressListener + + private fun service(): TTSForegroundService = controller.get() + + private fun getSegmentStart(): Long? { + val field = TTSForegroundService::class.java.getDeclaredField("speakingSegmentStartMs") + field.isAccessible = true + return field.get(service()) as Long? + } + + private fun setSegmentStart(value: Long) { + val field = TTSForegroundService::class.java.getDeclaredField("speakingSegmentStartMs") + field.isAccessible = true + field.set(service(), value) + } + + private fun getAccumulated(): Long { + val field = TTSForegroundService::class.java.getDeclaredField("spokenAccumulatedMs") + field.isAccessible = true + return field.getLong(service()) + } + + @Before + fun setUp() { + closeable = mockConstruction(TextToSpeech::class.java) + + controller = Robolectric.buildService(TTSForegroundService::class.java, Intent()) + controller.create() + val service = controller.get() + service.onInit(TextToSpeech.SUCCESS) + + // Capture the UtteranceProgressListener the service registered on the TTS mock. + val ttsMock = closeable.constructed().first() + val registration = mockingDetails(ttsMock).invocations.first { + it.method.name == "setOnUtteranceProgressListener" + } + listener = registration.arguments[0] as UtteranceProgressListener + } + + @After + fun tearDown() { + closeable.close() + controller.destroy() + } + + @Test + fun testSegmentOpensOnStartAndClosesOnDrainedDone() { + assert(getSegmentStart() == null) + assert(service().getSpokenPlaybackMs() == 0L) + + listener.onStart("u1") + assert(getSegmentStart() != null) + assert(service().isSpeakingActive()) + + // Backdate the segment start, then drain: accumulated time must reflect it. + setSegmentStart(SystemClock.elapsedRealtime() - 5000) + assert(service().getSpokenPlaybackMs() >= 5000) + + listener.onDone("u1") + assert(getSegmentStart() == null) + assert(!service().isSpeakingActive()) + val accumulated = getAccumulated() + assert(accumulated >= 5000) + + // Closed clock is stable (no growth without audio). + assert(service().getSpokenPlaybackMs() == accumulated) + } + + @Test + fun testOpenIsIdempotentAndDoubleCloseIsSafe() { + listener.onStart("u1") + val first = getSegmentStart() + listener.onStart("u2") + assert(getSegmentStart() == first) + + listener.onDone("u1") + listener.onDone("u2") + val accumulated = getAccumulated() + listener.onDone("u3") + assert(getAccumulated() == accumulated) + } + + @Test + fun testErrorOnDrainedQueueClosesSegment() { + listener.onStart("u1") + assert(getSegmentStart() != null) + listener.onError("u1") + assert(getSegmentStart() == null) + } + + @Test + fun testStopAndPauseCloseOpenSegment() { + listener.onStart("u1") + assert(getSegmentStart() != null) + service().stopAudioKeepService() + assert(getSegmentStart() == null) + + listener.onStart("u2") + assert(getSegmentStart() != null) + service().pauseTTSKeepService() + assert(getSegmentStart() == null) + + listener.onStart("u3") + assert(getSegmentStart() != null) + service().stopTTS() + assert(getSegmentStart() == null) + } +} diff --git a/jest.setup.js b/jest.setup.js index 1d2fb62f86..ccbfa650d3 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,3 +1,20 @@ +// Mock react-native-reanimated (ESM) — provide light stub for Animated.View etc. +jest.mock('react-native-reanimated', () => { + const React = require('react'); + const RN = require('react-native'); + const View = RN.View; + const MockedView = React.forwardRef((props, ref) => + React.createElement(View, { ...props, ref }), + ); + return { + __esModule: true, + default: { View: MockedView }, + Easing: { bezier: () => () => ({}) }, + ReduceMotion: { System: 0 }, + withTiming: v => v, + }; +}); + // Mock all native modules and problematic ESM dependencies globally // Mock react-native-device-info diff --git a/src/components/SearchbarV2/SearchbarV2.tsx b/src/components/SearchbarV2/SearchbarV2.tsx index b348ed94e7..4b46dae7df 100644 --- a/src/components/SearchbarV2/SearchbarV2.tsx +++ b/src/components/SearchbarV2/SearchbarV2.tsx @@ -1,5 +1,6 @@ import React, { memo, useRef, useState, useMemo } from 'react'; import { + I18nManager, Pressable, StyleSheet, TextInput, @@ -112,7 +113,13 @@ const Searchbar: React.FC = ({ style={styles.searchbar} > { if (handleBackAction) { diff --git a/src/components/Slider/Slider.tsx b/src/components/Slider/Slider.tsx index 812bb4405d..69b724b4fc 100644 --- a/src/components/Slider/Slider.tsx +++ b/src/components/Slider/Slider.tsx @@ -14,6 +14,7 @@ import { import Color from 'color'; import { useTheme } from '@hooks/persisted'; +import { setSliderDragging } from './sliderDragState'; const TOUCH_TARGET_HEIGHT = 48; const MAX_RENDERED_STOPS = 100; @@ -102,6 +103,8 @@ export interface SliderProps extends Omit< onValueChange?: (value: number) => void; onSlidingComplete?: (value: number) => void; style?: StyleProp; + /** Called when drag starts/ends so parents can disable competing gestures (AUD-GEST-02). */ + onDragStateChange?: (dragging: boolean) => void; } const Slider: React.FC = ({ @@ -119,6 +122,7 @@ const Slider: React.FC = ({ handleColor, onValueChange, onSlidingComplete, + onDragStateChange, style, testID = 'slider', ...viewProps @@ -128,15 +132,23 @@ const Slider: React.FC = ({ const [isActive, setIsActive] = useState(false); const [dragValue, setDragValue] = useState(null); const startXRef = React.useRef(0); + const touchStartXRef = React.useRef(0); + const panGestureRef = React.useRef(false); const disabledRef = React.useRef(disabled); disabledRef.current = disabled; + const isActiveRef = React.useRef(false); const onSlidingCompleteRef = React.useRef(onSlidingComplete); onSlidingCompleteRef.current = onSlidingComplete; const onValueChangeRef = React.useRef(onValueChange); onValueChangeRef.current = onValueChange; + const onDragStateChangeRef = React.useRef(onDragStateChange); + onDragStateChangeRef.current = onDragStateChange; + const dragFallbackTimerRef = React.useRef | null>(null); const sizeTokens = SIZE_TOKENS[size]; const containerHeight = Math.max( @@ -155,7 +167,13 @@ const Slider: React.FC = ({ const [lastControlledValue, setLastControlledValue] = useState(boundedValue); if (boundedValue !== lastControlledValue) { setLastControlledValue(boundedValue); - if (!isActive) setDragValue(null); + if (!isActive) { + setDragValue(null); + if (dragFallbackTimerRef.current) { + clearTimeout(dragFallbackTimerRef.current); + dragFallbackTimerRef.current = null; + } + } } const displayedValue = dragValue === null ? boundedValue : clamp(dragValue, min, safeMax); @@ -231,8 +249,23 @@ const Slider: React.FC = ({ (locationX: number) => { const completedValue = updateFromPosition(locationX); setIsActive(false); + isActiveRef.current = false; + setSliderDragging(false); + onDragStateChangeRef.current?.(false); if (completedValue === boundedValueRef.current) { setDragValue(null); + } else { + // AUD-GEST-03: keep drag value until controlled catches up, but + // schedule a fallback clear so a parent that rejects/clamps to the + // same value does not permanently freeze the thumb. + if (dragFallbackTimerRef.current) { + clearTimeout(dragFallbackTimerRef.current); + } + dragFallbackTimerRef.current = setTimeout(() => { + setDragValue(current => + current === completedValue ? null : current, + ); + }, 400); } onSlidingCompleteRef.current?.(completedValue); }, @@ -248,7 +281,10 @@ const Slider: React.FC = ({ const panResponder = useMemo( () => PanResponder.create({ - onStartShouldSetPanResponder: () => !disabledRef.current, + // AUD-GEST-01: defer claim to move so vertical scroll inside slider + // bounds is not blocked on ACTION_DOWN. Horizontal dominance is + // decided in onMoveShouldSetPanResponder via shouldClaimPanResponder. + onStartShouldSetPanResponder: () => false, onStartShouldSetPanResponderCapture: () => false, onMoveShouldSetPanResponder: (_evt, gestureState) => shouldClaimPanResponder( @@ -257,10 +293,17 @@ const Slider: React.FC = ({ disabledRef.current, ), onMoveShouldSetPanResponderCapture: () => false, - onPanResponderTerminationRequest: () => false, + // AUD-GEST-01: allow parent to steal the gesture when the slider + // is not actively dragging horizontally; deny only while mid-drag + // so vertical scroll isn't permanently locked out. + onPanResponderTerminationRequest: () => !isActiveRef.current, onPanResponderGrant: event => { if (disabledRef.current) return; + panGestureRef.current = true; setIsActive(true); + isActiveRef.current = true; + setSliderDragging(true); + onDragStateChangeRef.current?.(true); const locX = event.nativeEvent?.locationX ?? 0; startXRef.current = locX; updateFromPositionRef.current(locX); @@ -283,12 +326,18 @@ const Slider: React.FC = ({ }, onPanResponderTerminate: () => { if (disabledRef.current) return; + panGestureRef.current = false; setIsActive(false); - const currentVal = displayedValueRef.current; - if (currentVal === boundedValueRef.current) { - setDragValue(null); + isActiveRef.current = false; + setSliderDragging(false); + onDragStateChangeRef.current?.(false); + if (dragFallbackTimerRef.current) { + clearTimeout(dragFallbackTimerRef.current); + dragFallbackTimerRef.current = null; } - onSlidingCompleteRef.current?.(currentVal); + // AUD-GEST-02: native ViewPager stole the gesture; do not commit + // a half-dragged value. Revert to the controlled value. + setDragValue(null); }, }), [], @@ -300,6 +349,19 @@ const Slider: React.FC = ({ setWidth(nextWidth); }, []); + // Ensure global drag flag is cleared if the slider unmounts mid-drag (e.g. tab switch). + React.useEffect(() => { + return () => { + if (isActiveRef.current) { + setSliderDragging(false); + } + if (dragFallbackTimerRef.current) { + clearTimeout(dragFallbackTimerRef.current); + dragFallbackTimerRef.current = null; + } + }; + }, []); + const changeBy = useCallback( (amount: number) => { if (disabled) return; @@ -330,6 +392,25 @@ const Slider: React.FC = ({ { + panGestureRef.current = false; + touchStartXRef.current = event.nativeEvent.locationX; + viewProps.onTouchStart?.(event); + }} + onTouchEnd={event => { + if ( + !disabledRef.current && + !panGestureRef.current && + Math.abs(event.nativeEvent.locationX - touchStartXRef.current) < + HORIZONTAL_CLAIM_THRESHOLD + ) { + const nextValue = updateFromPositionRef.current( + event.nativeEvent.locationX, + ); + onSlidingCompleteRef.current?.(nextValue); + } + viewProps.onTouchEnd?.(event); + }} testID={testID} accessible accessibilityRole="adjustable" diff --git a/src/components/Slider/sliderDragState.ts b/src/components/Slider/sliderDragState.ts new file mode 100644 index 0000000000..440f6ee258 --- /dev/null +++ b/src/components/Slider/sliderDragState.ts @@ -0,0 +1,36 @@ +// Tracks whether any Slider instance is actively dragging so parents +// (e.g. ReaderBottomSheet TabView ViewPager) can disable competing +// horizontal gestures while a slider thumb is being dragged. +// AUD-GEST-02 fix: prevents native ViewPager from stealing a fast +// horizontal slider drag and causing a half-committed value + tab jump. +const listeners = new Set<(dragging: boolean) => void>(); +let dragging = false; + +export const setSliderDragging = (value: boolean): void => { + if (dragging === value) return; + dragging = value; + listeners.forEach(listener => { + try { + listener(value); + } catch { + // best-effort + } + }); +}; + +export const subscribeSliderDragging = ( + listener: (dragging: boolean) => void, +): (() => void) => { + listeners.add(listener); + // Sync initial state immediately + try { + listener(dragging); + } catch { + // ignore + } + return () => { + listeners.delete(listener); + }; +}; + +export const isSliderDragging = (): boolean => dragging; diff --git a/src/components/__tests__/Slider.test.tsx b/src/components/__tests__/Slider.test.tsx index 283a7f5396..05fe6a00cc 100644 --- a/src/components/__tests__/Slider.test.tsx +++ b/src/components/__tests__/Slider.test.tsx @@ -339,7 +339,7 @@ describe('Slider', () => { expect(onSlidingComplete).not.toHaveBeenCalled(); }); - it('claims responder at touch start while enabled to prevent pager interception', () => { + it('defers responder claim to move so vertical scroll is not blocked (AUD-GEST-01)', () => { const { rerender } = render(); const slider = screen.getByTestId('slider'); @@ -347,7 +347,8 @@ describe('Slider', () => { | ((event?: object) => boolean) | undefined; expect(startShouldSet).toBeDefined(); - expect(startShouldSet!()).toBe(true); + // AUD-GEST-01: start claim is deferred to onMoveShouldSetResponder + expect(startShouldSet!()).toBe(false); rerender(); expect(slider.props.onStartShouldSetResponder!()).toBe(false); diff --git a/src/database/migrations/007_add_novel_inLibrary_index.ts b/src/database/migrations/007_add_novel_inLibrary_index.ts new file mode 100644 index 0000000000..1f0234b841 --- /dev/null +++ b/src/database/migrations/007_add_novel_inLibrary_index.ts @@ -0,0 +1,32 @@ +import { Migration } from '../types/migration'; +import { createRateLimitedLogger } from '@utils/rateLimitedLogger'; + +const migration007Log = createRateLimitedLogger('Migration007', { + windowMs: 1500, +}); + +/** + * Migration 7: Dedicated index for WHERE inLibrary = 1 queries + * - NovelIndex is composite (pluginId, path, id, inLibrary) and cannot be used + * for SQLite index seek on bare `WHERE inLibrary = 1` (leftmost-prefix rule). + * - StatsQueries aggregates scan Novel filtered only by inLibrary; this index + * enables index seek instead of full table scan. + */ +export const migration007: Migration = { + version: 7, + description: 'Add index on Novel(inLibrary) for library stats queries', + migrate: db => { + try { + db.runSync( + 'CREATE INDEX IF NOT EXISTS idx_novel_inLibrary ON Novel(inLibrary)', + ); + } catch (error) { + migration007Log.error( + 'create-failed', + 'Failed to create idx_novel_inLibrary index', + error, + ); + throw error; + } + }, +}; diff --git a/src/database/migrations/__tests__/006_readingSession.migration.test.ts b/src/database/migrations/__tests__/006_readingSession.migration.test.ts index 5967d99d6d..86fb7f07d0 100644 --- a/src/database/migrations/__tests__/006_readingSession.migration.test.ts +++ b/src/database/migrations/__tests__/006_readingSession.migration.test.ts @@ -66,7 +66,7 @@ const insertChapter = (adapter: ExpoLikeDb, id: number, novelId: number) => { }; describe('Migration 006 — ReadingSession', () => { - it('fresh install (v2) → runner creates ReadingSession with FK + indexes → version 6', () => { + it('fresh install (v2) → runner creates ReadingSession with FK + indexes → version 7', () => { const { adapter } = createExpoLikeDb(); seedCurrentSchema(adapter); adapter.execSync('PRAGMA user_version = 2'); @@ -74,7 +74,7 @@ describe('Migration 006 — ReadingSession', () => { runRunner(adapter); expect(adapter.getFirstSync('PRAGMA user_version')).toEqual({ - user_version: 6, + user_version: 7, }); const tables = adapter.getAllSync<{ name: string }>( @@ -149,7 +149,7 @@ describe('Migration 006 — ReadingSession', () => { expect(() => runRunner(adapter)).not.toThrow(); expect(adapter.getFirstSync('PRAGMA user_version')).toEqual({ - user_version: 6, + user_version: 7, }); expect(adapter.getAllSync('SELECT * FROM ReadingSession')).toHaveLength(1); }); diff --git a/src/database/migrations/__tests__/migrationRunner.upgrade-path.integration.test.ts b/src/database/migrations/__tests__/migrationRunner.upgrade-path.integration.test.ts index 45d0240f21..cf3c402ae2 100644 --- a/src/database/migrations/__tests__/migrationRunner.upgrade-path.integration.test.ts +++ b/src/database/migrations/__tests__/migrationRunner.upgrade-path.integration.test.ts @@ -166,7 +166,7 @@ const runRunner = (adapter: ExpoLikeDb) => { }; describe('MigrationRunner upgrade paths → migration006', () => { - it('fresh install: createInitialSchema-equivalent (v0→2) → runner → version 6, julianday triggers + ReadingSession', () => { + it('fresh install: createInitialSchema-equivalent (v0→2) → runner → version 7, julianday triggers + ReadingSession', () => { const { adapter } = createExpoLikeDb(); seedCurrentSchema(adapter); adapter.execSync('PRAGMA user_version = 2'); @@ -174,7 +174,7 @@ describe('MigrationRunner upgrade paths → migration006', () => { runRunner(adapter); expect(adapter.getFirstSync('PRAGMA user_version')).toEqual({ - user_version: 6, + user_version: 7, }); expect(triggerNames(adapter)).toEqual([ 'add_category', @@ -187,7 +187,7 @@ describe('MigrationRunner upgrade paths → migration006', () => { ); }); - it('v1-era upgrade: bare tables without counters/triggers → runner adds 002/003/004/005/006 → version 6', () => { + it('v1-era upgrade: bare tables without counters/triggers → runner adds 002/003/004/005/006 → version 7', () => { const { adapter } = createExpoLikeDb(); seedV1Schema(adapter); adapter.execSync('PRAGMA user_version = 1'); @@ -203,7 +203,7 @@ describe('MigrationRunner upgrade paths → migration006', () => { runRunner(adapter); expect(adapter.getFirstSync('PRAGMA user_version')).toEqual({ - user_version: 6, + user_version: 7, }); // 002 added the counter columns. @@ -247,7 +247,7 @@ describe('MigrationRunner upgrade paths → migration006', () => { runRunner(adapter); expect(adapter.getFirstSync('PRAGMA user_version')).toEqual({ - user_version: 6, + user_version: 7, }); expect(normalizeSql(triggerSql(adapter, 'update_novel_stats'))).toBe( normalizeSql(createNovelTriggerQueryInsert), @@ -260,7 +260,7 @@ describe('MigrationRunner upgrade paths → migration006', () => { expect(novel?.lastUpdatedAt).toBe('2026-08-01 09:00:00'); }); - it('v3 upgrade (ttsState already present): runner applies 004 + 005 + 006 → version 6', () => { + it('v3 upgrade (ttsState already present): runner applies 004 + 005 + 006 → version 7', () => { const { adapter } = createExpoLikeDb(); seedV2WithOldTriggers(adapter); adapter.execSync('PRAGMA user_version = 3'); @@ -268,7 +268,7 @@ describe('MigrationRunner upgrade paths → migration006', () => { runRunner(adapter); expect(adapter.getFirstSync('PRAGMA user_version')).toEqual({ - user_version: 6, + user_version: 7, }); expect(triggerNames(adapter)).toHaveLength(4); expect(triggerSql(adapter, 'update_novel_stats')).toContain('julianday'); @@ -339,7 +339,7 @@ describe('MigrationRunner upgrade paths → migration006', () => { expect(novel.lastUpdatedAt).toBe(expected?.v ?? null); } expect(adapter.getFirstSync('PRAGMA user_version')).toEqual({ - user_version: 6, + user_version: 7, }); }); }); diff --git a/src/database/migrations/index.ts b/src/database/migrations/index.ts index ba38481eba..b91bdbf509 100644 --- a/src/database/migrations/index.ts +++ b/src/database/migrations/index.ts @@ -14,6 +14,7 @@ import { migration003 } from './003_add_tts_state'; import { migration004 } from './004_recreate_novel_triggers'; import { migration005 } from './005_add_repository_enabled'; import { migration006 } from './006_add_reading_time_tracking'; +import { migration007 } from './007_add_novel_inLibrary_index'; export const migrations: Migration[] = [ migration002, @@ -21,4 +22,5 @@ export const migrations: Migration[] = [ migration004, migration005, migration006, + migration007, ]; diff --git a/src/database/queries/StatsQueries.ts b/src/database/queries/StatsQueries.ts index 0b2a469379..d063728122 100644 --- a/src/database/queries/StatsQueries.ts +++ b/src/database/queries/StatsQueries.ts @@ -207,6 +207,7 @@ export const getReadingTimeGroupedByChapter = async (): Promise< export interface AggregateStats extends LibraryStats { totalReadingTime?: number; + readingChapters?: number; } interface AggregateRow { @@ -216,6 +217,7 @@ interface AggregateRow { chaptersUnread: number; chaptersDownloaded: number; totalReadingTime: number | null; + readingChapters: number; } const getAggregateStatsQuery = ` @@ -225,7 +227,8 @@ const getAggregateStatsQuery = ` COALESCE(SUM(totalChapters), 0) as chaptersCount, COALESCE(SUM(chaptersUnread), 0) as chaptersUnread, COALESCE(SUM(chaptersDownloaded), 0) as chaptersDownloaded, - COALESCE((SELECT SUM(duration) FROM ReadingSession JOIN Novel n2 ON ReadingSession.novelId = n2.id WHERE n2.inLibrary = 1), 0) as totalReadingTime + COALESCE((SELECT SUM(duration) FROM ReadingSession JOIN Novel n2 ON ReadingSession.novelId = n2.id WHERE n2.inLibrary = 1), 0) as totalReadingTime, + COALESCE((SELECT COUNT(DISTINCT ReadingSession.chapterId) FROM ReadingSession JOIN Novel n2 ON ReadingSession.novelId = n2.id WHERE n2.inLibrary = 1), 0) as readingChapters FROM Novel WHERE inLibrary = 1 `; @@ -243,6 +246,7 @@ export const getAggregateStatsFromDb = async (): Promise => { chaptersDownloaded: row.chaptersDownloaded ?? 0, chaptersRead: Math.max(0, chaptersCount - chaptersUnread), totalReadingTime: row.totalReadingTime ?? 0, + readingChapters: row.readingChapters ?? 0, }; }; diff --git a/src/database/types/index.ts b/src/database/types/index.ts index f78daaf44d..265b9192c6 100644 --- a/src/database/types/index.ts +++ b/src/database/types/index.ts @@ -108,6 +108,8 @@ export interface LibraryStats { genres?: Record; status?: Record; totalReadingTime?: number; + /** Number of distinct chapters represented by recorded reading sessions. */ + readingChapters?: number; } export interface BackupNovel extends NovelInfo { diff --git a/src/hooks/persisted/__tests__/readingTime.simulation.test.ts b/src/hooks/persisted/__tests__/readingTime.simulation.test.ts new file mode 100644 index 0000000000..28c1341024 --- /dev/null +++ b/src/hooks/persisted/__tests__/readingTime.simulation.test.ts @@ -0,0 +1,269 @@ +/** + * Reading-time simulation: manual scroll vs TTS read → Statistics (Time tab). + * + * Bridges the mocked seam between useTimeTracking.test.ts (hook → db mock) + * and StatsQueries.readingTime.test.ts (query → helper mock): here the mocked + * db actually accumulates ReadingSession rows, and the real StatsScreen + * formatters assert what the user would see. + * + * Reproduces the user report: a short in-reader test shows 0 total time. + */ +import { renderHook, act } from '@testing-library/react-native'; +import { AppState } from 'react-native'; +import { useTimeTracking } from '../useTimeTracking'; +import { + formatTimeSpent, + formatTotalTimeParts, +} from '@screens/StatsScreen/utils'; + +interface SessionRow { + novelId: number; + chapterId: number; + startTime: number; + duration: number; +} + +// In-memory ReadingSession stand-in: collects what the hook INSERTs. +const sessionRows: SessionRow[] = []; + +jest.mock('@database/db', () => ({ + db: { + runAsync: jest.fn((sql: string, ...params: number[]) => { + const [novelId, chapterId, startTime, duration] = params; + sessionRows.push({ novelId, chapterId, startTime, duration }); + return Promise.resolve(undefined); + }), + }, +})); + +// Mirrors getTotalReadingTime: SELECT COALESCE(SUM(duration), 0) +const simulatedTotalReadingTime = () => + sessionRows.reduce((sum, r) => sum + r.duration, 0); + +describe('reading-time simulation (scroll vs TTS → Statistics)', () => { + let appStateListener: ((state: string) => void) | null = null; + + beforeEach(() => { + sessionRows.length = 0; + jest.clearAllMocks(); + jest.useFakeTimers(); + AppState.currentState = 'active'; + jest + .spyOn(AppState, 'addEventListener') + .mockImplementation((_, handler: any) => { + appStateListener = handler; + return { remove: jest.fn() } as any; + }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('(a) manual scroll: 20s session only lands in Statistics after exiting the reader', async () => { + const { result, unmount } = renderHook(() => + useTimeTracking({ + novelId: 1, + chapterId: 10, + enabled: true, + inactivityTimeoutMs: 0, // app default: never auto-pause + isTTSActive: false, + }), + ); + + // Simulate core.js scroll posts (~every 2s) for 20s of manual reading + for (let i = 0; i < 10; i++) { + act(() => { + jest.advanceTimersByTime(2000); + }); + act(() => { + result.current.recordActivity(); + }); + } + + // Gotcha #1: stats queried while still in the reader see NOTHING — + // the session only INSERTs on flush (unmount / chapter change / background) + expect(simulatedTotalReadingTime()).toBe(0); + + // Exit the reader → unmount flush + await act(async () => { + unmount(); + }); + + expect(sessionRows).toHaveLength(1); + expect(sessionRows[0]).toMatchObject({ + novelId: 1, + chapterId: 10, + duration: 20000, + }); + + // 20s is recorded AND visible: the Time tab shows seconds precision + const total = simulatedTotalReadingTime(); + expect(formatTotalTimeParts(total)).toEqual({ + days: 0, + hours: 0, + minutes: 0, + seconds: 20, + }); + expect(formatTimeSpent(total)).toBe('20s'); + }); + + it('(b) TTS read: 45s session (incl. background) lands after TTS stops', async () => { + const isTTSActiveRef = { current: true }; + const { rerender } = renderHook( + ({ isTTS }: { isTTS: boolean }) => + useTimeTracking({ + novelId: 2, + chapterId: 20, + enabled: true, + inactivityTimeoutMs: 5000, + isTTSActive: isTTS, + isTTSActiveRef, + }), + { initialProps: { isTTS: true } }, + ); + + act(() => { + jest.advanceTimersByTime(15000); + }); + // Screen off mid-playback: TTS tracking continues (manual would flush here) + act(() => { + appStateListener?.('background'); + }); + act(() => { + jest.advanceTimersByTime(30000); + }); + + // User stops TTS → session flushes + isTTSActiveRef.current = false; + await act(async () => { + rerender({ isTTS: false }); + }); + + expect(sessionRows).toHaveLength(1); + expect(sessionRows[0]).toMatchObject({ + novelId: 2, + chapterId: 20, + duration: 45000, + }); + + // Same seconds precision as manual mode + expect(formatTimeSpent(simulatedTotalReadingTime())).toBe('45s'); + }); + + it('sessions accumulate across checkpoints: 5min manual read shows 5m', async () => { + const { result, unmount } = renderHook(() => + useTimeTracking({ + novelId: 3, + chapterId: 30, + enabled: true, + inactivityTimeoutMs: 0, + isTTSActive: false, + }), + ); + + // 5 minutes of scrolling (60s checkpoint splits it into multiple rows) + for (let i = 0; i < 150; i++) { + act(() => { + jest.advanceTimersByTime(2000); + }); + act(() => { + result.current.recordActivity(); + }); + } + await act(async () => { + unmount(); + }); + + // SUM(duration) is what Statistics displays — row split is irrelevant + expect(simulatedTotalReadingTime()).toBe(300000); + expect(formatTotalTimeParts(simulatedTotalReadingTime())).toEqual({ + days: 0, + hours: 0, + minutes: 5, + seconds: 0, + }); + expect(formatTimeSpent(simulatedTotalReadingTime())).toBe('5m'); + }); + + it('(d) combined manual reading + TTS: statistics shows the sum of both', async () => { + const isTTSActiveRef = { current: false }; + const { result, unmount, rerender } = renderHook( + ({ isTTS }: { isTTS: boolean }) => + useTimeTracking({ + novelId: 4, + chapterId: 40, + enabled: true, + inactivityTimeoutMs: 0, + isTTSActive: isTTS, + isTTSActiveRef, + }), + { initialProps: { isTTS: false } }, + ); + + // 1. Manual reading for 20 seconds with user activity (e.g. scrolling) + for (let i = 0; i < 10; i++) { + act(() => { + jest.advanceTimersByTime(2000); + }); + act(() => { + result.current.recordActivity(); + }); + } + + // 2. User starts TTS: manual session flushes, TTS starts + isTTSActiveRef.current = true; + await act(async () => { + rerender({ isTTS: true }); + }); + + // 3. TTS plays for 40 seconds (including 30s in background) + act(() => { + jest.advanceTimersByTime(10000); + }); + act(() => { + appStateListener?.('background'); + }); + act(() => { + jest.advanceTimersByTime(30000); + }); + + // 4. Return to foreground and stop TTS + act(() => { + appStateListener?.('active'); + }); + isTTSActiveRef.current = false; + await act(async () => { + rerender({ isTTS: false }); + }); + + // 5. Exit reader + await act(async () => { + unmount(); + }); + + // Both manual session (20s) and TTS session (40s) are recorded in ReadingSession + expect(sessionRows).toHaveLength(2); + expect(sessionRows[0]).toMatchObject({ + novelId: 4, + chapterId: 40, + duration: 20000, + }); + expect(sessionRows[1]).toMatchObject({ + novelId: 4, + chapterId: 40, + duration: 40000, + }); + + // Total reading time in Statistics reflects the exact sum of both (20s + 40s = 60s) + const total = simulatedTotalReadingTime(); + expect(total).toBe(60000); + expect(formatTotalTimeParts(total)).toEqual({ + days: 0, + hours: 0, + minutes: 1, + seconds: 0, + }); + expect(formatTimeSpent(total)).toBe('1m'); + }); +}); diff --git a/src/hooks/persisted/__tests__/useTimeTracking.reconcile.test.ts b/src/hooks/persisted/__tests__/useTimeTracking.reconcile.test.ts new file mode 100644 index 0000000000..5b25e65b15 --- /dev/null +++ b/src/hooks/persisted/__tests__/useTimeTracking.reconcile.test.ts @@ -0,0 +1,233 @@ +import { renderHook, act } from '@testing-library/react-native'; +import { AppState, NativeModules } from 'react-native'; +import { useTimeTracking } from '../useTimeTracking'; + +const mockRunAsync = jest.fn().mockResolvedValue(undefined); +const mockGetClock = jest.fn(); + +jest.mock('@database/db', () => ({ + db: { + runAsync: (...args: any[]) => mockRunAsync(...args), + }, +})); + +// The hook resolves NativeModules.TTSHighlight at call time, so a plain test +// double suffices — no react-native module mock needed. +const installTtsBridge = () => { + (NativeModules as any).TTSHighlight = { + getTtsPlaybackClock: (...args: any[]) => mockGetClock(...args), + }; +}; +const removeTtsBridge = () => { + delete (NativeModules as any).TTSHighlight; +}; + +const totalRecordedMs = (): number => + mockRunAsync.mock.calls.reduce((sum, c) => sum + (c[4] as number), 0); + +describe('useTimeTracking background TTS reconciliation (native clock)', () => { + let appStateListener: ((state: string) => void) | null = null; + // Mutable native speaking-time stand-in (ms of utterance-active audio). + let nativeMs = 0; + let nativeSpeaking = true; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + nativeMs = 0; + nativeSpeaking = true; + mockGetClock.mockImplementation(() => + Promise.resolve({ spokenMs: nativeMs, speaking: nativeSpeaking }), + ); + installTtsBridge(); + AppState.currentState = 'active'; + jest + .spyOn(AppState, 'addEventListener') + .mockImplementation((_, handler: any) => { + appStateListener = handler; + return { remove: jest.fn() } as any; + }); + }); + + afterEach(() => { + removeTtsBridge(); + jest.useRealTimers(); + }); + + it('does not double-count when JS timers stay alive in background', async () => { + const isTTSActiveRef = { current: true }; + const { unmount } = renderHook(() => + useTimeTracking({ + novelId: 20, + chapterId: 200, + enabled: true, + inactivityTimeoutMs: 0, + isTTSActiveRef, + }), + ); + + // 60s foreground (one checkpoint row) + act(() => { + jest.advanceTimersByTime(60000); + }); + expect(totalRecordedMs()).toBe(60000); + + // Background entry snapshots the native clock + nativeMs = 60000; + await act(async () => { + appStateListener?.('background'); + }); + + // Repeat background event (background -> inactive -> background) must + // settle, not reset, the running stretch: still no double-count. + await act(async () => { + appStateListener?.('background'); + }); + + // 120s background with JS alive: two checkpoint rows, no top-up yet + nativeMs = 120000; + act(() => { + jest.advanceTimersByTime(60000); + }); + nativeMs = 180000; + act(() => { + jest.advanceTimersByTime(60000); + }); + + // Foreground: flush + ~zero top-up, session resumes + await act(async () => { + appStateListener?.('active'); + }); + + await act(async () => { + unmount(); + }); + + // Exactly the 180s actually listened (60 fg + 120 bg) — no double count. + expect(mockRunAsync).toHaveBeenCalledTimes(3); + expect(totalRecordedMs()).toBe(180000); + }); + + it('recovers background time when TTS stopped while JS timers were frozen', async () => { + const isTTSActiveRef = { current: true }; + renderHook(() => + useTimeTracking({ + novelId: 21, + chapterId: 210, + enabled: true, + inactivityTimeoutMs: 0, + isTTSActiveRef, + }), + ); + + // 60s foreground, then background with native clock snapshotted + act(() => { + jest.advanceTimersByTime(60000); + }); + nativeMs = 60000; + await act(async () => { + appStateListener?.('background'); + }); + + // JS frozen for 300s (Doze): no checkpoints, no polls, stale heartbeat. + // Native kept speaking the whole stretch, then TTS stopped. + nativeMs = 360000; + nativeSpeaking = false; + jest.setSystemTime(Date.now() + 300000); + + // First timer tick after revive: stop detected, heartbeat stale so the + // JS flush is capped to ~nothing (and dropped under MIN_SESSION_MS). + isTTSActiveRef.current = false; + await act(async () => { + jest.advanceTimersByTime(800); + }); + + // Foreground: top-up recovers the 300s background stretch. + await act(async () => { + appStateListener?.('active'); + }); + + expect(mockRunAsync).toHaveBeenCalledTimes(2); + expect(totalRecordedMs()).toBe(360000); + }); + + it('does not start a phantom TTS session after a background stop', async () => { + const isTTSActiveRef = { current: true }; + renderHook(() => + useTimeTracking({ + novelId: 22, + chapterId: 220, + enabled: true, + inactivityTimeoutMs: 0, + isTTSActiveRef, + }), + ); + + act(() => { + jest.advanceTimersByTime(60000); + }); + nativeMs = 60000; + await act(async () => { + appStateListener?.('background'); + }); + + nativeMs = 120000; + nativeSpeaking = false; + jest.setSystemTime(Date.now() + 60000); + isTTSActiveRef.current = false; + await act(async () => { + jest.advanceTimersByTime(800); + }); + await act(async () => { + appStateListener?.('active'); + }); + const afterReconcile = totalRecordedMs(); + + // 60s of (manual) foreground: only manual time accrues, no phantom TTS. + // (59200 not 60000: the manual session starts mid checkpoint-phase after + // the 800ms poll tick; a phantom TTS session would add ~59s more.) + act(() => { + jest.advanceTimersByTime(60000); + }); + + const manualOnly = totalRecordedMs() - afterReconcile; + expect(manualOnly).toBeGreaterThanOrEqual(59000); + expect(manualOnly).toBeLessThan(90000); + }); + + it('fails open to legacy accounting when the native clock is unavailable', async () => { + mockGetClock.mockResolvedValue({ spokenMs: null, speaking: false }); + const isTTSActiveRef = { current: true }; + const { unmount } = renderHook(() => + useTimeTracking({ + novelId: 23, + chapterId: 230, + enabled: true, + inactivityTimeoutMs: 0, + isTTSActiveRef, + }), + ); + + act(() => { + jest.advanceTimersByTime(60000); + }); + await act(async () => { + appStateListener?.('background'); + }); + + jest.setSystemTime(Date.now() + 300000); + isTTSActiveRef.current = false; + await act(async () => { + jest.advanceTimersByTime(800); + }); + await act(async () => { + appStateListener?.('active'); + }); + await act(async () => { + unmount(); + }); + + // Only the foreground-attested time survives — same as before the fix. + expect(totalRecordedMs()).toBe(60000); + }); +}); diff --git a/src/hooks/persisted/__tests__/useTimeTracking.test.ts b/src/hooks/persisted/__tests__/useTimeTracking.test.ts index 400e50d15f..17c19fbe9b 100644 --- a/src/hooks/persisted/__tests__/useTimeTracking.test.ts +++ b/src/hooks/persisted/__tests__/useTimeTracking.test.ts @@ -86,8 +86,8 @@ describe('useTimeTracking (Dual-Mode: Manual + TTS)', () => { ); }); - it('auto-pauses manual reading after inactivity timeout', async () => { - renderHook(() => + it('auto-pauses manual reading after inactivity timeout (idle excluded)', async () => { + const { result } = renderHook(() => useTimeTracking({ novelId: 2, chapterId: 20, @@ -97,17 +97,26 @@ describe('useTimeTracking (Dual-Mode: Manual + TTS)', () => { }), ); - // Advance past inactivity timeout (5s) + // Simulate activity at 2s (e.g., scroll) – lastActivity = 2s + act(() => { + jest.advanceTimersByTime(2000); + }); + act(() => { + result.current.recordActivity(); + }); + + // Advance past inactivity timeout (5s after last activity → fires at ~7s) await act(async () => { jest.advanceTimersByTime(5001); }); + // AUD-TIME-02: idle window (5s) excluded, duration = lastActivity(2s) - start(0) = 2000 expect(mockRunAsync).toHaveBeenCalledWith( 'INSERT INTO ReadingSession (novelId, chapterId, startTime, duration) VALUES (?, ?, ?, ?)', 2, 20, expect.any(Number), - 5000, + 2000, ); }); @@ -213,4 +222,153 @@ describe('useTimeTracking (Dual-Mode: Manual + TTS)', () => { 4000, ); }); + + it('preserves full background TTS reading time when paused via lockscreen/media button', async () => { + const isTTSActiveRef = { current: true }; + renderHook(() => + useTimeTracking({ + novelId: 10, + chapterId: 100, + enabled: true, + inactivityTimeoutMs: 0, + isTTSActiveRef, + }), + ); + + // 10s playback in foreground + act(() => { + jest.advanceTimersByTime(10000); + }); + + // App enters background (screen turned off / user locks phone) + act(() => { + appStateListener?.('background'); + }); + + // User listens for 40 seconds in background + act(() => { + jest.advanceTimersByTime(40000); + }); + + // User pauses via lockscreen notification / headset (ref changes, no component re-render) + isTTSActiveRef.current = false; + + // Advance 700ms for polling interval to detect pause + await act(async () => { + jest.advanceTimersByTime(700); + }); + + // Total reading time must include foreground + background (~50s) + expect(mockRunAsync).toHaveBeenCalledWith( + 'INSERT INTO ReadingSession (novelId, chapterId, startTime, duration) VALUES (?, ?, ?, ?)', + 10, + 100, + expect.any(Number), + expect.any(Number), + ); + const call1 = mockRunAsync.mock.calls[mockRunAsync.mock.calls.length - 1]; + expect(call1[4]).toBeGreaterThanOrEqual(50000); + expect(call1[4]).toBeLessThanOrEqual(51500); + }); + + it('tracks background TTS playback started while screen is already off', async () => { + const isTTSActiveRef = { current: false }; + AppState.currentState = 'background'; + + renderHook(() => + useTimeTracking({ + novelId: 10, + chapterId: 100, + enabled: true, + inactivityTimeoutMs: 0, + isTTSActiveRef, + }), + ); + + // Screen has been off in background for 60 seconds + act(() => { + appStateListener?.('background'); + jest.advanceTimersByTime(60000); + }); + + // User presses Play on Bluetooth headset while screen is still off + isTTSActiveRef.current = true; + await act(async () => { + jest.advanceTimersByTime(700); + }); + + // Plays for 45 seconds in background + act(() => { + jest.advanceTimersByTime(45000); + }); + + // User presses Pause on Bluetooth headset + isTTSActiveRef.current = false; + await act(async () => { + jest.advanceTimersByTime(700); + }); + + // Must record full background session (~45s), NOT 0 + expect(mockRunAsync).toHaveBeenCalledWith( + 'INSERT INTO ReadingSession (novelId, chapterId, startTime, duration) VALUES (?, ?, ?, ?)', + 10, + 100, + expect.any(Number), + expect.any(Number), + ); + const call2 = mockRunAsync.mock.calls[mockRunAsync.mock.calls.length - 1]; + expect(call2[4]).toBeGreaterThanOrEqual(45000); + expect(call2[4]).toBeLessThanOrEqual(47000); + }); + + it('caps Doze drift if poller is suspended and wakes up hours later', async () => { + const isTTSActiveRef = { current: true }; + renderHook(() => + useTimeTracking({ + novelId: 10, + chapterId: 100, + enabled: true, + inactivityTimeoutMs: 0, + isTTSActiveRef, + }), + ); + + // Play for 30s + act(() => { + jest.advanceTimersByTime(30000); + }); + + // TTS stops at t = 30s + isTTSActiveRef.current = false; + + // Simulate extreme Doze suspension: JS timers frozen for 2 hours (7,200,000 ms) + const originalDateNow = Date.now; + try { + let fakeNow = originalDateNow() + 30000; + jest.spyOn(Date, 'now').mockImplementation(() => fakeNow); + + // 2 hours pass while device is suspended in Doze + fakeNow += 2 * 3600 * 1000; + + // Device wakes up and poller finally runs + await act(async () => { + jest.advanceTimersByTime(700); + }); + + // Duration should be capped to heartbeat (~30s) + grace window, NOT 2 hours! + expect(mockRunAsync).toHaveBeenCalledWith( + 'INSERT INTO ReadingSession (novelId, chapterId, startTime, duration) VALUES (?, ?, ?, ?)', + 10, + 100, + expect.any(Number), + expect.any(Number), + ); + const call = mockRunAsync.mock.calls[mockRunAsync.mock.calls.length - 1]; + const recordedDuration = call[4]; + expect(recordedDuration).toBeLessThanOrEqual(35000); + expect(recordedDuration).toBeGreaterThanOrEqual(30000); + } finally { + Date.now = originalDateNow; + } + }); }); diff --git a/src/hooks/persisted/useNovel.ts b/src/hooks/persisted/useNovel.ts index 2d1176f9aa..cea7614a01 100644 --- a/src/hooks/persisted/useNovel.ts +++ b/src/hooks/persisted/useNovel.ts @@ -429,7 +429,8 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => { const markChapterRead = useCallback( (chapterId: number) => { - _markChapterRead(chapterId); + // AUD-PERS-03: handle async rejection; DB may be busy/locked on backgrounding + _markChapterRead(chapterId).catch(() => {}); mutateChapters(chs => chs.map(c => { @@ -449,7 +450,8 @@ export const useNovel = (novelOrPath: string | NovelInfo, pluginId: string) => { const updateChapterProgress = useCallback( (chapterId: number, progress: number) => { const clampedProgress = Math.min(progress, 100); - _updateChapterProgress(chapterId, clampedProgress); + // AUD-PERS-03: attach rejection handling + _updateChapterProgress(chapterId, clampedProgress).catch(() => {}); mutateChapters(chs => chs.map(c => { diff --git a/src/hooks/persisted/useSettings.ts b/src/hooks/persisted/useSettings.ts index 3065664089..318af867cc 100644 --- a/src/hooks/persisted/useSettings.ts +++ b/src/hooks/persisted/useSettings.ts @@ -315,6 +315,14 @@ export interface ChapterGeneralSettings { * paragraphs, so the RN <-> WebView paragraph index contract stays intact. */ ttsTextCleanup: TtsTextCleanupSettings; + /** + * In-chapter search: what happens when the search bar is closed. + * - 'countdown': show 5s countdown banner and then return to the anchor scroll position before search (default) + * - 'immediate': return to anchor immediately without countdown + * - 'stay': stay at the current (search) position, never auto-return + * Search never mutates the saved last-read progress while open. + */ + searchReturnBehavior: 'countdown' | 'immediate' | 'stay'; } export interface ReaderTheme { @@ -466,6 +474,7 @@ export const initialChapterGeneralSettings: ChapterGeneralSettings = { continuousScrollStitchThreshold: 90, ttsShowGestureHints: true, ttsTextCleanup: DEFAULT_TTS_CLEANUP_SETTINGS, + searchReturnBehavior: 'countdown', }; export const initialChapterReaderSettings: ChapterReaderSettings = { diff --git a/src/hooks/persisted/useTimeTracking.ts b/src/hooks/persisted/useTimeTracking.ts index a077b78fa7..251007ef52 100644 --- a/src/hooks/persisted/useTimeTracking.ts +++ b/src/hooks/persisted/useTimeTracking.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef } from 'react'; -import { AppState, AppStateStatus } from 'react-native'; +import { AppState, AppStateStatus, NativeModules } from 'react-native'; import { createRateLimitedLogger } from '@utils/rateLimitedLogger'; import { db } from '@database/db'; @@ -27,6 +27,41 @@ export interface UseTimeTrackingReturn { } const MIN_SESSION_MS = 1000; +const MAX_SESSION_MS = 12 * 3600 * 1000; // 12h cap guards Date.now NTP/zone skew (AUD-TIME-05) +const CHECKPOINT_INTERVAL_MS = 60_000; +const CHECKPOINT_MIN_MS = 30_000; +const TTS_POLL_INTERVAL_MS = 700; +const TTS_HEARTBEAT_GRACE_MS = 2000; + +interface NativeTtsClock { + /** Total utterance-active ms this process lifetime, or null if unavailable. */ + spokenMs: number | null; + /** True while native audio is flowing (or queued behind an open segment). */ + speaking: boolean; +} + +/** + * Read the native speaking-time clock (TTSForegroundService.getSpokenPlaybackMs). + * Deliberately resolved via NativeModules at call time (not via TTSAudioManager + * or TTSHighlightService) so this hook never constructs a NativeEventEmitter at + * import time. Never rejects: null clock means fail open to JS-only accounting. + */ +const readNativeTtsClock = async (): Promise => { + try { + const clock = await ( + NativeModules as any + )?.TTSHighlight?.getTtsPlaybackClock?.(); + const spokenMs = + typeof clock?.spokenMs === 'number' && + Number.isFinite(clock.spokenMs) && + clock.spokenMs >= 0 + ? Math.floor(clock.spokenMs) + : null; + return { spokenMs, speaking: clock?.speaking === true }; + } catch { + return { spokenMs: null, speaking: false }; + } +}; /** * Production-safe dual-mode reading time tracker. @@ -58,13 +93,29 @@ export function useTimeTracking( const sessionManualNovelIdRef = useRef(undefined); const sessionManualChapterIdRef = useRef(undefined); const inactivityTimerRef = useRef | null>(null); + const lastActivityAtRef = useRef(null); + const checkpointIntervalRef = useRef | null>( + null, + ); // TTS mode state const ttsStartTimeRef = useRef(null); + const lastTtsHeartbeatRef = useRef(null); const isTtsTrackingRef = useRef(false); const sessionTtsNovelIdRef = useRef(undefined); const sessionTtsChapterIdRef = useRef(undefined); + // Background reconciliation state (native speaking-clock gap-filler). + // JS timers freeze under Doze while native TTS keeps speaking, so on every + // app-background entry we snapshot the native clock; on foreground (and + // unmount) we insert the delta minus whatever JS checkpoints already wrote. + // Invariant: bgJsRecordedMs only accumulates TTS inserts made while + // bgReconcileActive (see persistSession), so top-ups never double-count. + const bgReconcileActiveRef = useRef(false); + const bgWallStartRef = useRef(null); + const bgNativeBaselineRef = useRef(null); + const bgJsRecordedMsRef = useRef(0); + // Environment refs const appStateRef = useRef(AppState.currentState); const enabledRef = useRef(enabled); @@ -96,6 +147,21 @@ export function useTimeTracking( } }, []); + const sanitizeDuration = useCallback((duration: number): number => { + if (!Number.isFinite(duration) || duration < 0) { + timeTrackLog.debug('duration-non-monotonic', `duration=${duration}`); + return 0; + } + if (duration > MAX_SESSION_MS) { + timeTrackLog.warn( + 'duration-capped', + `capped ${duration} to ${MAX_SESSION_MS}`, + ); + return MAX_SESSION_MS; + } + return duration; + }, []); + const persistSession = useCallback( async ( nId: number | undefined, @@ -105,6 +171,7 @@ export function useTimeTracking( mode: 'manual' | 'tts', reason: string, ) => { + duration = sanitizeDuration(duration); if (duration < MIN_SESSION_MS) { timeTrackLog.debug( `${mode}-flush-skip-short`, @@ -116,6 +183,10 @@ export function useTimeTracking( timeTrackLog.debug(`${mode}-flush-skip-missing-ids`, `${reason}`); return; } + // Capture the background window BEFORE the async write: a foreground + // checkpoint whose DB write resolves after background entry must not + // count toward the background top-up baseline (else top-ups undercount). + const bgCounted = mode === 'tts' && bgReconcileActiveRef.current; try { await db.runAsync( 'INSERT INTO ReadingSession (novelId, chapterId, startTime, duration) VALUES (?, ?, ?, ?)', @@ -124,6 +195,9 @@ export function useTimeTracking( startTime, Math.round(duration), ); + if (bgCounted) { + bgJsRecordedMsRef.current += Math.round(duration); + } timeTrackLog.debug( `${mode}-flushed`, `${reason} novel=${nId} chapter=${cId} duration=${duration}`, @@ -132,7 +206,62 @@ export function useTimeTracking( timeTrackLog.warn(`${mode}-insert-failed`, String(e)); } }, - [], + [sanitizeDuration], + ); + + /** + * Insert the background TTS time the JS session could not attest: + * native delta since the background snapshot minus JS inserts already + * recorded in this background stretch. Ran on foreground and unmount. + * No-op when the clock is unavailable (fail open to legacy accounting). + */ + const topUpBackgroundTts = useCallback( + async ( + nId: number | undefined, + cId: number | undefined, + reason: string, + ) => { + if (!bgReconcileActiveRef.current) return; + if ( + bgNativeBaselineRef.current == null || + bgWallStartRef.current == null + ) { + return; + } + const baseline = bgNativeBaselineRef.current; + const wallStart = bgWallStartRef.current; + const clock = await readNativeTtsClock(); + const nativeNow = clock.spokenMs; + if (nativeNow == null) return; + const wallNow = Date.now(); + if (nativeNow < baseline) { + // Native service restarted (process death) — clock reset, nothing to attest. + timeTrackLog.warn( + 'tts-clock-reset', + `baseline=${baseline} now=${nativeNow}`, + ); + bgNativeBaselineRef.current = nativeNow; + bgJsRecordedMsRef.current = 0; + bgWallStartRef.current = wallNow; + return; + } + // Advance baseline first so repeated top-ups never double-count. + const unrecorded = nativeNow - baseline - bgJsRecordedMsRef.current; + bgNativeBaselineRef.current = nativeNow; + bgJsRecordedMsRef.current = 0; + bgWallStartRef.current = wallNow; + // Never credit more than real wall-clock elapsed in background. + const topUp = Math.min( + Math.max(0, unrecorded), + Math.max(0, wallNow - wallStart), + ); + if (topUp < MIN_SESSION_MS) { + timeTrackLog.debug('tts-topup-skip-short', `${reason} topUp=${topUp}`); + return; + } + await persistSession(nId, cId, wallStart, topUp, 'tts', reason); + }, + [persistSession], ); const doFlushManual = useCallback( @@ -141,21 +270,28 @@ export function useTimeTracking( clearInactivityTimer(); return; } - const now = Date.now(); const startTime = manualStartTimeRef.current; - const duration = now - startTime; + let duration: number; + // AUD-TIME-02: exclude idle window from inactivity flush + if (reason === 'inactivity' && lastActivityAtRef.current != null) { + duration = lastActivityAtRef.current - startTime; + } else { + duration = Date.now() - startTime; + } + duration = sanitizeDuration(duration); const nId = sessionManualNovelIdRef.current; const cId = sessionManualChapterIdRef.current; clearInactivityTimer(); isManualTrackingRef.current = false; manualStartTimeRef.current = null; + lastActivityAtRef.current = null; sessionManualNovelIdRef.current = undefined; sessionManualChapterIdRef.current = undefined; await persistSession(nId, cId, startTime, duration, 'manual', reason); }, - [clearInactivityTimer, persistSession], + [clearInactivityTimer, persistSession, sanitizeDuration], ); const doFlushTts = useCallback( @@ -163,20 +299,68 @@ export function useTimeTracking( if (!isTtsTrackingRef.current || ttsStartTimeRef.current === null) { return; } - const now = Date.now(); const startTime = ttsStartTimeRef.current; - const duration = now - startTime; + const now = Date.now(); + let endTime = now; + + // AUD-TIME-03 (Corrected): If the poller fired late due to Android Doze / JS suspension, + // cap endTime to the last confirmed active heartbeat (+ poll interval). + // If playback paused normally in background (headset/notification/audio-end), + // now - lastTtsHeartbeatRef <= TTS_HEARTBEAT_GRACE_MS, so legitimate background playback + // is fully preserved without truncation. + if ( + reason === 'tts-inactive-poll' && + lastTtsHeartbeatRef.current !== null && + now - lastTtsHeartbeatRef.current > TTS_HEARTBEAT_GRACE_MS + ) { + endTime = Math.max( + startTime, + lastTtsHeartbeatRef.current + TTS_POLL_INTERVAL_MS, + ); + timeTrackLog.warn( + 'tts-flush-capped-doze-drift', + `capped drift from ${now - startTime}ms to ${endTime - startTime}ms`, + ); + } + + let duration = endTime - startTime; + // NATIVE-CLOCK CAP: while backgrounded, the JS-attested span cannot exceed + // what the native speaking clock vouches for (+ pre-background elapsed + + // grace). This bounds the foreground-revive flush when TTS already stopped + // mid-background; the remainder is recovered via topUpBackgroundTts. + if ( + bgReconcileActiveRef.current && + bgNativeBaselineRef.current != null && + bgWallStartRef.current != null + ) { + const clock = await readNativeTtsClock(); + const nativeNow = clock.spokenMs; + if (nativeNow != null && nativeNow >= bgNativeBaselineRef.current) { + const allowed = + Math.max(0, bgWallStartRef.current - startTime) + + (nativeNow - bgNativeBaselineRef.current) + + TTS_HEARTBEAT_GRACE_MS; + if (duration > allowed) { + timeTrackLog.warn( + 'tts-flush-capped-native', + `capped ${duration}ms to ${allowed}ms`, + ); + duration = allowed; + } + } + } + duration = sanitizeDuration(duration); const nId = sessionTtsNovelIdRef.current; const cId = sessionTtsChapterIdRef.current; - isTtsTrackingRef.current = false; ttsStartTimeRef.current = null; + lastTtsHeartbeatRef.current = null; sessionTtsNovelIdRef.current = undefined; sessionTtsChapterIdRef.current = undefined; await persistSession(nId, cId, startTime, duration, 'tts', reason); }, - [persistSession], + [persistSession, sanitizeDuration], ); const scheduleInactivityTimer = useCallback(() => { @@ -200,7 +384,9 @@ export function useTimeTracking( return; } if (novelIdRef.current == null || chapterIdRef.current == null) return; - manualStartTimeRef.current = Date.now(); + const now = Date.now(); + manualStartTimeRef.current = now; + lastActivityAtRef.current = now; sessionManualNovelIdRef.current = novelIdRef.current; sessionManualChapterIdRef.current = chapterIdRef.current; isManualTrackingRef.current = true; @@ -216,7 +402,9 @@ export function useTimeTracking( if (!enabledRef.current) return; if (!ttsActiveRef.current) return; if (novelIdRef.current == null || chapterIdRef.current == null) return; - ttsStartTimeRef.current = Date.now(); + const now = Date.now(); + ttsStartTimeRef.current = now; + lastTtsHeartbeatRef.current = now; sessionTtsNovelIdRef.current = novelIdRef.current; sessionTtsChapterIdRef.current = chapterIdRef.current; isTtsTrackingRef.current = true; @@ -226,12 +414,70 @@ export function useTimeTracking( ); }, []); + /** Snapshot the native clock on app-background entry (best-effort). */ + const enterBackground = useCallback(async () => { + if (bgReconcileActiveRef.current) { + // Repeat background event without an intervening foreground (e.g. + // background -> inactive -> background): settle the running stretch + // first so the re-snapshot cannot double-count it. + await topUpBackgroundTts( + novelIdRef.current, + chapterIdRef.current, + 'background-reentry', + ); + } + bgReconcileActiveRef.current = true; + bgWallStartRef.current = Date.now(); + bgJsRecordedMsRef.current = 0; + const clock = await readNativeTtsClock(); + bgNativeBaselineRef.current = clock.spokenMs; + if (bgNativeBaselineRef.current == null) { + timeTrackLog.debug( + 'tts-clock-unavailable', + 'background baseline missed, top-ups disabled', + ); + } + }, [topUpBackgroundTts]); + + /** + * Foreground reconcile: flush the (native-capped) open session, top up the + * remainder the JS session could not attest, then reopen the TTS session if + * native audio is still flowing (the 700ms poll only reacts to *changes*). + */ + const reconcileForeground = useCallback(async () => { + if (!bgReconcileActiveRef.current) return; + await doFlushTts('appstate-active'); + await topUpBackgroundTts( + novelIdRef.current, + chapterIdRef.current, + 'foreground-reconcile', + ); + const clock = await readNativeTtsClock(); + bgReconcileActiveRef.current = false; + bgNativeBaselineRef.current = null; + bgJsRecordedMsRef.current = 0; + bgWallStartRef.current = null; + // Fail open when the clock is unavailable; otherwise only resume if native + // audio is actually flowing (prevents phantom sessions after a + // background stop the JS thread never observed). + const stillPlaying = + ttsActiveRef.current && (clock.spokenMs == null || clock.speaking); + if (enabledRef.current && stillPlaying) { + tryStartTts(); + } else if (enabledRef.current && !ttsActiveRef.current) { + // TTS ended while backgrounded — resume manual tracking instead. + // (Touch/scroll also restarts it via recordActivity.) + tryStartManual(); + } + }, [doFlushTts, topUpBackgroundTts, tryStartManual, tryStartTts]); + const recordActivity = useCallback(() => { if (ttsActiveRef.current) return; if (!isManualTrackingRef.current) { tryStartManual(); return; } + lastActivityAtRef.current = Date.now(); scheduleInactivityTimer(); }, [scheduleInactivityTimer, tryStartManual]); @@ -270,12 +516,31 @@ export function useTimeTracking( // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabled, isTTSActive, novelId, chapterId]); - // Chapter/novel change: flush previous session and start new + // Chapter/novel change: flush previous session and start new (AUD-TIME-06) + // Timer cleanup is scoped to actual chapter/novel changes, not every re-render, + // to avoid clearing the scheduled starter on unrelated dep changes. const prevChapterIdInternalRef = useRef(chapterId); const prevNovelIdInternalRef = useRef(novelId); const chapterChangeTimerRef = useRef | null>( null, ); + // Refs for callbacks to avoid effect re-trigger on identity change + const doFlushManualRef = useRef(doFlushManual); + const doFlushTtsRef = useRef(doFlushTts); + const tryStartManualRef = useRef(tryStartManual); + const tryStartTtsRef = useRef(tryStartTts); + useEffect(() => { + doFlushManualRef.current = doFlushManual; + }, [doFlushManual]); + useEffect(() => { + doFlushTtsRef.current = doFlushTts; + }, [doFlushTts]); + useEffect(() => { + tryStartManualRef.current = tryStartManual; + }, [tryStartManual]); + useEffect(() => { + tryStartTtsRef.current = tryStartTts; + }, [tryStartTts]); useEffect(() => { const chapterChanged = prevChapterIdInternalRef.current !== chapterId; @@ -283,55 +548,67 @@ export function useTimeTracking( if (chapterChanged || novelChanged) { if (isManualTrackingRef.current) { - void doFlushManual('chapter-change'); + void doFlushManualRef.current('chapter-change'); } if (isTtsTrackingRef.current) { - void doFlushTts('chapter-change'); + void doFlushTtsRef.current('chapter-change'); } prevChapterIdInternalRef.current = chapterId; prevNovelIdInternalRef.current = novelId; if (chapterChangeTimerRef.current) { clearTimeout(chapterChangeTimerRef.current); + chapterChangeTimerRef.current = null; } if (enabled && novelId != null && chapterId != null) { chapterChangeTimerRef.current = setTimeout(() => { + chapterChangeTimerRef.current = null; if (ttsActiveRef.current) { - tryStartTts(); + tryStartTtsRef.current(); } else if ( appStateRef.current !== 'background' && appStateRef.current !== 'inactive' ) { - tryStartManual(); + tryStartManualRef.current(); } }, 0); } } + // Cleanup only the timer for this chapter change, not on every dep churn + return () => { + // Do not clear here unless unmount; chapter-change timer is one-shot + }; + }, [chapterId, novelId, enabled]); + + // Unmount cleanup for chapterChangeTimer + useEffect(() => { return () => { if (chapterChangeTimerRef.current) { clearTimeout(chapterChangeTimerRef.current); chapterChangeTimerRef.current = null; } }; - }, [ - chapterId, - novelId, - enabled, - doFlushManual, - doFlushTts, - tryStartManual, - tryStartTts, - ]); + }, []); - // AppState listener (manual pauses in background, TTS continues) + // AppState listener (manual pauses in background, TTS reconciles via native clock) useEffect(() => { const sub = AppState.addEventListener('change', (next: AppStateStatus) => { const prev = appStateRef.current; appStateRef.current = next; if (next === 'background' || next === 'inactive') { void doFlushManual(`appstate-${next}`); + } + // Reconcile only on true backgrounding; 'inactive' is transitional and + // the native TTS clock only matters for Android background playback. + if (next === 'background') { + void enterBackground(); } else if (next === 'active' && prev !== 'active') { - if (enabledRef.current && !ttsActiveRef.current) { + // Always reconcile first: recovers background TTS time even when TTS + // already ended mid-background (mirror ref already false), then falls + // back to manual tracking. Skipped entirely if never backgrounded. + if (bgReconcileActiveRef.current) { + void reconcileForeground(); + } else if (enabledRef.current && !ttsActiveRef.current) { tryStartManual(); } } @@ -340,7 +617,13 @@ export function useTimeTracking( sub.remove(); clearInactivityTimer(); }; - }, [clearInactivityTimer, doFlushManual, tryStartManual]); + }, [ + clearInactivityTimer, + doFlushManual, + tryStartManual, + enterBackground, + reconcileForeground, + ]); // Start on mount if eligible useEffect(() => { @@ -357,6 +640,13 @@ export function useTimeTracking( return () => { void doFlushManual('unmount'); void doFlushTts('unmount'); + // Best-effort: credit background speaking time when the reader closes + // while backgrounded (e.g. stop-then-immediately-back). + void topUpBackgroundTts( + novelIdRef.current, + chapterIdRef.current, + 'unmount', + ); clearInactivityTimer(); }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -369,11 +659,71 @@ export function useTimeTracking( } }, [inactivityTimeoutMs, scheduleInactivityTimer]); + // AUD-TIME-04: Periodic checkpoint to limit loss on SIGKILL / LMK. + // Inserts incremental sessions every CHECKPOINT_INTERVAL_MS and resets start, + // so at most one interval is lost if the process is killed. + useEffect(() => { + if (!enabled) return; + const id = setInterval(() => { + const now = Date.now(); + if ( + isManualTrackingRef.current && + manualStartTimeRef.current != null && + !ttsActiveRef.current + ) { + const lastActivity = lastActivityAtRef.current; + if ( + lastActivity != null && + inactivityMsRef.current > 0 && + now - lastActivity >= inactivityMsRef.current + ) { + void doFlushManual('inactivity'); + return; + } + const dur = sanitizeDuration(now - manualStartTimeRef.current); + if (dur >= CHECKPOINT_MIN_MS) { + const nId = sessionManualNovelIdRef.current; + const cId = sessionManualChapterIdRef.current; + const start = manualStartTimeRef.current; + // Reset before async to avoid double-count on next tick + manualStartTimeRef.current = now; + lastActivityAtRef.current = now; + void persistSession(nId, cId, start, dur, 'manual', 'checkpoint'); + } + } + if (isTtsTrackingRef.current && ttsStartTimeRef.current != null) { + const rawDur = now - ttsStartTimeRef.current; + const dur = sanitizeDuration( + Math.min(rawDur, CHECKPOINT_INTERVAL_MS + TTS_HEARTBEAT_GRACE_MS), + ); + if (dur >= CHECKPOINT_MIN_MS) { + const nId = sessionTtsNovelIdRef.current; + const cId = sessionTtsChapterIdRef.current; + const start = ttsStartTimeRef.current; + ttsStartTimeRef.current = now; + lastTtsHeartbeatRef.current = now; + void persistSession(nId, cId, start, dur, 'tts', 'checkpoint'); + } + } + }, CHECKPOINT_INTERVAL_MS); + // @ts-ignore + id.unref?.(); + checkpointIntervalRef.current = id; + return () => { + clearInterval(id); + checkpointIntervalRef.current = null; + }; + }, [doFlushManual, enabled, persistSession, sanitizeDuration]); + // Poll TTS ref for changes that don't trigger re-render useEffect(() => { if (!isTTSActiveRef) return; const interval = setInterval(() => { const current = getIsTTSActive(); + if (current) { + // Record heartbeat while TTS is confirmed active + lastTtsHeartbeatRef.current = Date.now(); + } if (current !== ttsActiveRef.current) { ttsActiveRef.current = current; if (current) { @@ -393,7 +743,7 @@ export function useTimeTracking( } } } - }, 700); + }, TTS_POLL_INTERVAL_MS); // @ts-ignore - NodeJS vs RN timeout types interval.unref?.(); return () => clearInterval(interval); diff --git a/src/screens/StatsScreen/StatsScreen.tsx b/src/screens/StatsScreen/StatsScreen.tsx index 6a663650a2..cd5ca39844 100644 --- a/src/screens/StatsScreen/StatsScreen.tsx +++ b/src/screens/StatsScreen/StatsScreen.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { StyleSheet, useWindowDimensions } from 'react-native'; -import { useNavigation } from '@react-navigation/native'; +import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { SceneRendererProps, TabView, @@ -55,8 +55,12 @@ const StatsScreen = () => { mountedRef.current = false; }; }, []); + // Silent refresh after first load: refocusing (e.g. back from a novel + // opened via Top-novels-by-time) must pick up new ReadingSession rows + // without flashing the full-screen loader. + const hasLoadedRef = React.useRef(false); const load = useCallback(async () => { - setIsLoading(true); + if (!hasLoadedRef.current) setIsLoading(true); setError(undefined); try { const [agg, novelsWithGenres, top] = await Promise.all([ @@ -80,6 +84,7 @@ const StatsScreen = () => { setStats(merged); setNovels(novelsWithGenres); setTopNovels(top); + hasLoadedRef.current = true; } catch (e) { if (mountedRef.current) setError(e); } finally { @@ -87,9 +92,13 @@ const StatsScreen = () => { } }, []); - useEffect(() => { - load(); - }, [load]); + // Refetch on focus (fires on mount too): Time-tab rows navigate to + // ReaderStack Novel, so returning must show newly recorded time. + useFocusEffect( + useCallback(() => { + load(); + }, [load]), + ); const routes: Route[] = useMemo( () => [ diff --git a/src/screens/StatsScreen/__tests__/StatsScreen.test.tsx b/src/screens/StatsScreen/__tests__/StatsScreen.test.tsx new file mode 100644 index 0000000000..212711f9f9 --- /dev/null +++ b/src/screens/StatsScreen/__tests__/StatsScreen.test.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react-native'; +import StatsScreen from '../StatsScreen'; + +let focusCb: (() => void) | null = null; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ goBack: jest.fn() }), + useFocusEffect: jest.fn((cb: () => void) => { + focusCb = cb; + }), +})); + +jest.mock('@database/queries/StatsQueries', () => ({ + getAggregateStatsFromDb: jest.fn(), + getNovelsWithGenresFromDb: jest.fn(), + getTopNovelsByReadingTimeFromDb: jest.fn(), + splitCsvField: jest.fn((v: string) => (v ? v.split(',') : [])), +})); + +jest.mock('@hooks/persisted', () => ({ + useTheme: () => ({ + primary: '#6750a4', + surface: '#fff', + rippleColor: '#ccc', + secondary: '#666', + onSurfaceVariant: '#444', + }), + useAppSettings: () => ({ uiScale: 1 }), +})); + +jest.mock('@strings/translations', () => ({ + getString: (key: string) => key, +})); + +jest.mock('@components', () => { + const { View, Text } = require('react-native'); + return { + Appbar: () => null, + ErrorScreenV2: () => null, + LoadingScreenV2: () => STATS_LOADING, + SafeAreaView: ({ children }: any) => {children}, + TopTabBar: () => null, + }; +}); + +jest.mock('react-native-tab-view', () => ({ + TabView: ({ navigationState, renderScene }: any) => + renderScene({ route: navigationState.routes[navigationState.index] }), +})); + +jest.mock('../components/OverviewTab', () => { + const { Text } = require('react-native'); + return ({ stats }: any) => ( + OVERVIEW:{stats.totalReadingTime ?? 'none'} + ); +}); +jest.mock('../components/PluginsTab', () => () => null); +jest.mock('../components/TimeTab', () => { + const { Text } = require('react-native'); + return ({ stats }: any) => ( + TIME:{stats.totalReadingTime ?? 'none'} + ); +}); + +const { + getAggregateStatsFromDb, + getNovelsWithGenresFromDb, + getTopNovelsByReadingTimeFromDb, +} = require('@database/queries/StatsQueries'); + +const flush = () => + act(async () => { + await new Promise(r => setTimeout(r, 0)); + }); + +describe('StatsScreen — focus refetch', () => { + beforeEach(() => { + jest.clearAllMocks(); + focusCb = null; + (getAggregateStatsFromDb as jest.Mock).mockResolvedValue({ + totalReadingTime: 1000, + }); + (getNovelsWithGenresFromDb as jest.Mock).mockResolvedValue([]); + (getTopNovelsByReadingTimeFromDb as jest.Mock).mockResolvedValue([]); + }); + + it('loads on focus and silently refreshes on refocus (no loader flash)', async () => { + render(); + expect(focusCb).not.toBeNull(); + + // First focus: full-screen loader, then data + act(() => { + focusCb!(); + }); + expect(screen.getByText('STATS_LOADING')).toBeTruthy(); + await flush(); + await flush(); + expect(screen.getByText('OVERVIEW:1000')).toBeTruthy(); + expect(screen.queryByText('STATS_LOADING')).toBeNull(); + expect(getAggregateStatsFromDb).toHaveBeenCalledTimes(1); + + // New session recorded elsewhere → refocus picks it up silently + (getAggregateStatsFromDb as jest.Mock).mockResolvedValue({ + totalReadingTime: 5000, + }); + act(() => { + focusCb!(); + }); + expect(screen.queryByText('STATS_LOADING')).toBeNull(); + await flush(); + await flush(); + expect(getAggregateStatsFromDb).toHaveBeenCalledTimes(2); + expect(screen.getByText('OVERVIEW:5000')).toBeTruthy(); + expect(screen.queryByText('STATS_LOADING')).toBeNull(); + }); +}); diff --git a/src/screens/StatsScreen/__tests__/TimeTab.test.tsx b/src/screens/StatsScreen/__tests__/TimeTab.test.tsx index bfefd5a06c..9a11f3a479 100644 --- a/src/screens/StatsScreen/__tests__/TimeTab.test.tsx +++ b/src/screens/StatsScreen/__tests__/TimeTab.test.tsx @@ -35,6 +35,7 @@ jest.mock('@strings/translations', () => ({ 'statsScreen.days': 'days', 'statsScreen.hours': 'hours', 'statsScreen.mins': 'mins', + 'statsScreen.secs': 'secs', 'statsScreen.total': 'total', 'statsScreen.readingVelocity': 'Reading velocity', 'statsScreen.chaptersPerHour': 'chapters / hour', @@ -68,11 +69,27 @@ describe('TimeTab — UI & Navigation Regression', () => { expect(screen.getByText('No reading time recorded yet.')).toBeTruthy(); }); + it('shows seconds so sub-minute sessions visibly register', () => { + render( + , + ); + expect(screen.getByText('20')).toBeTruthy(); + expect(screen.getByText('secs')).toBeTruthy(); + expect(screen.getByText('20s total')).toBeTruthy(); + }); + it('calculates reading velocity when time is >= 1 minute', () => { // 2 hours = 7200000 ms, 10 chapters render( , ); diff --git a/src/screens/StatsScreen/__tests__/utils.test.ts b/src/screens/StatsScreen/__tests__/utils.test.ts index 9f64e814d1..f5e1fb0ac1 100644 --- a/src/screens/StatsScreen/__tests__/utils.test.ts +++ b/src/screens/StatsScreen/__tests__/utils.test.ts @@ -12,8 +12,10 @@ describe('stats utils', () => { expect(formatTimeSpent(0)).toBe('0m'); expect(formatTimeSpent(-100)).toBe('0m'); }); - it('formats <1m', () => { - expect(formatTimeSpent(30000)).toBe('<1m'); + it('formats seconds under a minute', () => { + expect(formatTimeSpent(30000)).toBe('30s'); + expect(formatTimeSpent(20000)).toBe('20s'); + expect(formatTimeSpent(1000)).toBe('1s'); }); it('formats minutes', () => { expect(formatTimeSpent(90_000)).toBe('1m'); @@ -36,16 +38,25 @@ describe('stats utils', () => { days: 0, hours: 0, minutes: 0, + seconds: 0, + }); + expect(formatTotalTimeParts(20000)).toEqual({ + days: 0, + hours: 0, + minutes: 0, + seconds: 20, }); expect(formatTotalTimeParts(90 * 60000)).toEqual({ days: 0, hours: 1, minutes: 30, + seconds: 0, }); expect(formatTotalTimeParts(1500 * 60000)).toEqual({ days: 1, hours: 1, minutes: 0, + seconds: 0, }); }); }); diff --git a/src/screens/StatsScreen/components/DistributionBar.tsx b/src/screens/StatsScreen/components/DistributionBar.tsx index ba1a8707da..9e2acdf103 100644 --- a/src/screens/StatsScreen/components/DistributionBar.tsx +++ b/src/screens/StatsScreen/components/DistributionBar.tsx @@ -75,9 +75,6 @@ const DistributionBar: React.FC = ({ entries, colors, total }) => { const innerR = outerR * 0.62; const cx = size / 2; const cy = size / 2; - const isSingleFull = - entries.length === 1 && Math.abs(entries[0].value - sum) < 0.001; - const visibleEntries: Entry[] = []; let otherValue = 0; for (const e of entries) { @@ -93,6 +90,19 @@ const DistributionBar: React.FC = ({ entries, colors, total }) => { }); } + // Latent 360° arc collapse (AUD-STAT-01): SVG arc with identical start/end coords is omitted. + // Use Circle fallback when a single visible slice spans ~360° (covers raw single-entry + // and multi-entry where one value dominates 100% after small-slice filtering). + const isSingleFull = + visibleEntries.length === 1 && + Math.abs(visibleEntries[0].value - sum) < 0.001; + // Also detect near-full sweep that would collapse even with rounding + const isEffectivelyFull = + visibleEntries.length === 1 && + sum > 0 && + (visibleEntries[0].value / sum) * 360 >= 359.9; + const useCircleFallback = isSingleFull || isEffectivelyFull; + let angle = 0; const segments = visibleEntries .map(e => { @@ -115,13 +125,13 @@ const DistributionBar: React.FC = ({ entries, colors, total }) => { - {isSingleFull ? ( + {useCircleFallback ? ( <> diff --git a/src/screens/StatsScreen/components/GenreSection.tsx b/src/screens/StatsScreen/components/GenreSection.tsx index 1357470595..d513ca8848 100644 --- a/src/screens/StatsScreen/components/GenreSection.tsx +++ b/src/screens/StatsScreen/components/GenreSection.tsx @@ -13,7 +13,7 @@ const GenreSection: React.FC = ({ tree }) => { const theme = useTheme(); const { uiScale = 1.0 } = useAppSettings(); const styles = React.useMemo(() => createStyles(uiScale), [uiScale]); - const max = Math.max(...tree.map(n => n.count), 1); + const max = tree.reduce((m, n) => (n.count > m ? n.count : m), 1); const [expanded, setExpanded] = useState>(new Set()); const toggle = (genre: string) => { diff --git a/src/screens/StatsScreen/components/PluginsTab.tsx b/src/screens/StatsScreen/components/PluginsTab.tsx index efe09f009f..adaf47d18a 100644 --- a/src/screens/StatsScreen/components/PluginsTab.tsx +++ b/src/screens/StatsScreen/components/PluginsTab.tsx @@ -38,11 +38,15 @@ const PluginsTab: React.FC = ({ novels }) => { .sort((a, b) => b.count - a.count); }, [novels]); - const entries = grouped.map(g => ({ - key: g.pluginId, - value: g.count, - label: g.name, - })); + const entries = useMemo( + () => + grouped.map(g => ({ + key: g.pluginId, + value: g.count, + label: g.name, + })), + [grouped], + ); const palette = useMemo( () => getDonutPalette( @@ -75,8 +79,8 @@ const PluginsTab: React.FC = ({ novels }) => { {grouped.map(group => { - const maxChapters = Math.max( - ...group.novels.map(n => n.totalChapters), + const maxChapters = group.novels.reduce( + (m, n) => (n.totalChapters > m ? n.totalChapters : m), 1, ); return ( diff --git a/src/screens/StatsScreen/components/TimeTab.tsx b/src/screens/StatsScreen/components/TimeTab.tsx index 220e495462..2d49e3697d 100644 --- a/src/screens/StatsScreen/components/TimeTab.tsx +++ b/src/screens/StatsScreen/components/TimeTab.tsx @@ -25,14 +25,17 @@ const TimeTab: React.FC = ({ stats, topNovels }) => { const totalMs = stats.totalReadingTime ?? 0; const parts = formatTotalTimeParts(totalMs); + // Use only chapters represented in ReadingSession. chaptersRead is a lifetime + // library counter and predates reading-time tracking. const velocity = useMemo(() => { - const chaptersRead = stats.chaptersRead ?? 0; + const chaptersRead = stats.readingChapters ?? 0; if (totalMs < 60000 || !chaptersRead) return null; const hours = totalMs / 3600000; - const cph = hours ? chaptersRead / hours : 0; - const minsPerChapter = chaptersRead ? totalMs / 60000 / chaptersRead : 0; + const cph = chaptersRead / hours; + const minsPerChapter = totalMs / 60000 / chaptersRead; + if (!Number.isFinite(cph) || !Number.isFinite(minsPerChapter)) return null; return { cph, minsPerChapter }; - }, [stats.chaptersRead, totalMs]); + }, [stats.readingChapters, totalMs]); return ( @@ -79,6 +82,19 @@ const TimeTab: React.FC = ({ stats, topNovels }) => { {getString('statsScreen.mins')} + + + {parts.seconds} + + + {getString('statsScreen.secs')} + + {formatTimeSpent(totalMs)} {getString('statsScreen.total')} diff --git a/src/screens/StatsScreen/utils.ts b/src/screens/StatsScreen/utils.ts index d7432a093e..84a03c5026 100644 --- a/src/screens/StatsScreen/utils.ts +++ b/src/screens/StatsScreen/utils.ts @@ -2,8 +2,9 @@ import Color from 'color'; export const formatTimeSpent = (ms: number): string => { if (!Number.isFinite(ms) || ms <= 0) return '0m'; - const totalMinutes = Math.floor(ms / 60000); - if (totalMinutes < 1) return '<1m'; + const totalSeconds = Math.floor(ms / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + const totalMinutes = Math.floor(totalSeconds / 60); const days = Math.floor(totalMinutes / 1440); const hours = Math.floor((totalMinutes % 1440) / 60); const minutes = totalMinutes % 60; @@ -16,13 +17,17 @@ export const formatTimeSpent = (ms: number): string => { export const formatTotalTimeParts = ( ms: number, -): { days: number; hours: number; minutes: number } => { - if (!Number.isFinite(ms) || ms <= 0) return { days: 0, hours: 0, minutes: 0 }; - const totalMinutes = Math.floor(ms / 60000); +): { days: number; hours: number; minutes: number; seconds: number } => { + if (!Number.isFinite(ms) || ms <= 0) { + return { days: 0, hours: 0, minutes: 0, seconds: 0 }; + } + const totalSeconds = Math.floor(ms / 1000); + const totalMinutes = Math.floor(totalSeconds / 60); const days = Math.floor(totalMinutes / 1440); const hours = Math.floor((totalMinutes % 1440) / 60); const minutes = totalMinutes % 60; - return { days, hours, minutes }; + const seconds = totalSeconds % 60; + return { days, hours, minutes, seconds }; }; export const getDonutPalette = ( @@ -45,12 +50,21 @@ export const getDonutPalette = ( }; // Genre taxonomy helpers +// Unicode-aware: preserves CJK, Cyrillic, accented letters via \p{L}\p{N} export const normalizeGenre = (genre: string): string => { const trimmed = genre.trim(); if (!trimmed) return ''; - const lower = trimmed.toLowerCase().replace(/[^a-z0-9]/g, ''); + let lower: string; + try { + lower = trimmed.toLocaleLowerCase().replace(/[^\p{L}\p{N}]/gu, ''); + } catch { + // Fallback for engines without Unicode property escapes + lower = trimmed.toLocaleLowerCase().replace(/[^a-z0-9]/g, ''); + } if (!lower) return ''; - return lower.charAt(0).toUpperCase() + lower.slice(1); + // Uppercase first codepoint (handles single-char CJK correctly as no-op) + const first = lower.charAt(0).toLocaleUpperCase(); + return first + lower.slice(1); }; export interface TaxonomyNode { diff --git a/src/screens/reader/ReaderScreen.tsx b/src/screens/reader/ReaderScreen.tsx index e546b0abc7..227c805f00 100644 --- a/src/screens/reader/ReaderScreen.tsx +++ b/src/screens/reader/ReaderScreen.tsx @@ -1,5 +1,15 @@ -import React, { useRef, useCallback, useState, useEffect } from 'react'; -import { useChapterGeneralSettings, useTheme } from '@hooks/persisted'; +import React, { + useRef, + useCallback, + useState, + useEffect, + useMemo, +} from 'react'; +import { + useAppSettings, + useChapterGeneralSettings, + useTheme, +} from '@hooks/persisted'; import ReaderAppbar from './components/ReaderAppbar'; import ReaderFooter from './components/ReaderFooter'; @@ -12,13 +22,16 @@ import ChapterDrawer from './components/ChapterDrawer'; import ChapterLoadingScreen from './ChapterLoadingScreen/ChapterLoadingScreen'; import { ErrorScreenV2 } from '@components'; import { ChapterScreenProps } from '@navigators/types'; +import { scaleDimension } from '@theme/scaling'; import { getString } from '@strings/translations'; import KeepScreenAwake from './components/KeepScreenAwake'; import { ChapterContextProvider, useChapterContext } from './ChapterContext'; +import { useNovelContext } from '@screens/novel/NovelContext'; import { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types'; +import { useScaledDimensions } from '@hooks/useScaledDimensions'; import { useBackHandler } from '@hooks/index'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { StyleSheet, View } from 'react-native'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; import { Drawer } from 'react-native-drawer-layout'; import { ReaderSearchResult, EMPTY_READER_SEARCH_RESULT } from './types'; @@ -67,7 +80,10 @@ export const ChapterContent = ({ navigation, openDrawer, }: ChapterContentProps) => { - const { left, right } = useSafeAreaInsets(); + const { bottom, left, right } = useSafeAreaInsets(); + const { statusBarHeight } = useNovelContext(); + const scaledDimensions = useScaledDimensions(); + const { uiScale = 1.0 } = useAppSettings(); const { novel, chapter, @@ -77,14 +93,18 @@ export const ChapterContent = ({ } = useChapterContext(); const readerSheetRef = useRef(null); const theme = useTheme(); - const { pageReader = false, keepScreenOn } = useChapterGeneralSettings(); + const { + pageReader = false, + keepScreenOn, + searchReturnBehavior = 'countdown', + } = useChapterGeneralSettings(); const [bookmarked, setBookmarked] = useState(chapter.bookmark); useEffect(() => { setBookmarked(chapter.bookmark); }, [chapter]); - const { hidden, loading, error, webViewRef, hideHeader, refetch } = + const { hidden, setHidden, loading, error, webViewRef, hideHeader, refetch } = useChapterContext(); // ── In-chapter search state ────────────────────────────────────────── @@ -93,6 +113,139 @@ export const ChapterContent = ({ EMPTY_READER_SEARCH_RESULT, ); const searchQueryRef = useRef(''); + // Anchor-preservation + countdown return (5s). Search never mutates last-read while open. + const [showReturnBanner, setShowReturnBanner] = useState(false); + const [returnCountdown, setReturnCountdown] = useState(5); + const returnTimerRef = useRef | null>(null); + const returnBehaviorRef = useRef(searchReturnBehavior); + useEffect(() => { + returnBehaviorRef.current = searchReturnBehavior; + }, [searchReturnBehavior]); + + const bannerStyles = useMemo( + () => + StyleSheet.create({ + returnBanner: { + position: 'absolute', + bottom: Math.max(16, bottom + scaledDimensions.margin.sm), + left: scaledDimensions.margin.md, + right: scaledDimensions.margin.md, + borderRadius: scaledDimensions.borderRadius.lg, + borderWidth: 1, + paddingHorizontal: scaledDimensions.padding.md, + paddingVertical: scaledDimensions.padding.sm, + elevation: 4, + zIndex: 5, + }, + returnBannerText: { + fontSize: scaleDimension(14, uiScale), + fontWeight: '500', + marginBottom: scaledDimensions.margin.xs, + textAlign: 'center', + }, + returnBannerActions: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + }, + returnBannerButton: { + paddingHorizontal: scaledDimensions.padding.md, + paddingVertical: scaledDimensions.padding.xs + 2, + borderRadius: scaledDimensions.borderRadius.xl, + minWidth: scaledDimensions.buttonHeight.lg * 2, + alignItems: 'center', + marginHorizontal: scaledDimensions.margin.xs, + }, + returnBannerButtonText: { + fontSize: scaleDimension(13, uiScale), + fontWeight: '600', + }, + }), + [bottom, scaledDimensions, uiScale], + ); + + const clearReturnTimer = useCallback(() => { + if (returnTimerRef.current) { + clearInterval(returnTimerRef.current); + returnTimerRef.current = null; + } + }, []); + + const dismissReturnBanner = useCallback(() => { + clearReturnTimer(); + setShowReturnBanner(false); + setReturnCountdown(5); + // Clear anchor — user chose to stay where search left them. + webViewRef?.current?.injectJavaScript( + `(function(){ + try { + window.__searchAnchorY = null; + window.__searchAnchorPage = null; + window.__searchAnchorPIdx = null; + window.__isSearching = false; + if (window.reader && typeof window.reader.saveProgress === 'function') { + window.reader.saveProgress(); + } + } catch(e) {} + })(); true;`, + ); + }, [clearReturnTimer, webViewRef]); + + const executeReturnToAnchor = useCallback(() => { + clearReturnTimer(); + setShowReturnBanner(false); + setReturnCountdown(5); + webViewRef?.current?.injectJavaScript( + `(function(){ + try { + var y = window.__searchAnchorY; + var p = window.__searchAnchorPage; + var isPage = !!(window.reader && window.reader.generalSettings && window.reader.generalSettings.val && window.reader.generalSettings.val.pageReader && window.pageReader && p != null); + + if (window.tts && window.tts.reading && window.tts.currentElement && typeof window.tts.scrollToElement === 'function') { + // Priority 1: Only when TTS is actively reading; otherwise anchor (y/p) is authoritative. + window.tts.scrollToElement(window.tts.currentElement); + } else if (isPage) { + // Priority 2: Paged reader mode + window.pageReader.movePage(p); + } else if (y != null) { + // Priority 3: Scroll mode + window.scrollTo({ top: y, behavior: 'smooth' }); + } + window.__searchAnchorY = null; + window.__searchAnchorPage = null; + window.__searchAnchorPIdx = null; + window.__isSearching = false; + window.__searchSaveBypassUntil = 0; + } catch(e) {} + })(); true;`, + ); + }, [clearReturnTimer, webViewRef]); + + // Countdown effect + useEffect(() => { + if (!showReturnBanner) return; + returnTimerRef.current = setInterval(() => { + setReturnCountdown(prev => { + if (prev <= 1) { + clearReturnTimer(); + executeReturnToAnchor(); + return 5; + } + return prev - 1; + }); + }, 1000); + return () => clearReturnTimer(); + }, [showReturnBanner, clearReturnTimer, executeReturnToAnchor]); + + // Cleanup timer on unmount / chapter change + useEffect(() => { + return () => clearReturnTimer(); + }, [clearReturnTimer]); + useEffect(() => { + dismissReturnBanner(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chapter.id]); const handleSearch = useCallback( (query: string) => { @@ -119,22 +272,89 @@ export const ChapterContent = ({ const handleClearSearch = useCallback(() => { searchQueryRef.current = ''; setSearchResult(EMPTY_READER_SEARCH_RESULT); - webViewRef?.current?.injectJavaScript('window.readerSearch.clear(); true;'); + webViewRef?.current?.injectJavaScript( + 'try{window.readerSearch.clear();}catch(e){} true;', + ); }, [webViewRef]); const handleToggleSearch = useCallback(() => { setSearchVisible(prev => { + const next = !prev; if (prev) { + // Closing via toggle (same as handleCloseSearch but without banner — toggle is explicit dismiss) + dismissReturnBanner(); + webViewRef?.current?.injectJavaScript( + `(function(){ + try { + window.__isSearching = false; + window.__searchAnchorY = null; + window.__searchAnchorPage = null; + window.__searchAnchorPIdx = null; + } catch(e) {} + })(); true;`, + ); handleClearSearch(); + } else { + // Opening: capture anchor before any search scroll mutates position + webViewRef?.current?.injectJavaScript( + `(function(){ + try { + var y = window.scrollY || window.pageYOffset || (document.documentElement && document.documentElement.scrollTop) || (document.body && document.body.scrollTop) || 0; + var p = (window.pageReader && window.pageReader.page) ? window.pageReader.page.val : null; + var pIdx = (window.reader && typeof window.reader.getVisibleElementIndex === 'function') ? window.reader.getVisibleElementIndex() : -1; + window.__searchAnchorY = y; + window.__searchAnchorPage = p; + window.__searchAnchorPIdx = pIdx; + window.__isSearching = true; + } catch(e) {} + })(); true;`, + ); + if (hidden) { + setHidden(false); + webViewRef?.current?.injectJavaScript( + 'reader.hidden.val = false; true;', + ); + } + clearReturnTimer(); + setShowReturnBanner(false); + setReturnCountdown(5); } - return !prev; + return next; }); - }, [handleClearSearch]); + }, [ + handleClearSearch, + hidden, + setHidden, + webViewRef, + dismissReturnBanner, + clearReturnTimer, + ]); const handleCloseSearch = useCallback(() => { + // Capture whether we had an anchor before clearing + const behavior = returnBehaviorRef.current; + webViewRef?.current?.injectJavaScript( + 'try{window.__isSearching=false;}catch(e){} true;', + ); setSearchVisible(false); handleClearSearch(); - }, [handleClearSearch]); + if (behavior === 'stay') { + dismissReturnBanner(); + return; + } + if (behavior === 'immediate') { + executeReturnToAnchor(); + return; + } + // countdown (default): show banner, auto-return in 5s unless dismissed + setReturnCountdown(5); + setShowReturnBanner(true); + }, [ + handleClearSearch, + executeReturnToAnchor, + dismissReturnBanner, + webViewRef, + ]); // Clear search on chapter change useEffect(() => { @@ -142,12 +362,16 @@ export const ChapterContent = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [chapter.id]); - // ── Back handler: search takes priority over drawer ────────────────── + // ── Back handler: search & return banner take priority over drawer ─── useBackHandler(() => { if (searchVisible) { handleCloseSearch(); return true; } + if (showReturnBanner) { + dismissReturnBanner(); + return true; + } return false; }); @@ -206,6 +430,7 @@ export const ChapterContent = ({ onPress={hideHeader} onSearchResult={setSearchResult} searchQuery={searchResult.query} + isSearchActive={searchVisible || showReturnBanner} /> )} - {!hidden && ( - <> - {searchVisible ? ( - - ) : ( - - )} - {!searchVisible && ( - - )} - + {searchVisible && ( + + )} + {!hidden && !searchVisible && ( + + )} + {!hidden && !searchVisible && ( + + )} + {showReturnBanner && ( + + + {getString('readerScreen.search.returnBannerCountdown', { + seconds: returnCountdown, + })} + + + + + {getString('readerScreen.search.returnNow')} + + + + + {getString('readerScreen.search.stayHere')} + + + + )} ); diff --git a/src/screens/reader/components/ReaderAppbar.tsx b/src/screens/reader/components/ReaderAppbar.tsx index 9f649848d0..fb304e65a3 100644 --- a/src/screens/reader/components/ReaderAppbar.tsx +++ b/src/screens/reader/components/ReaderAppbar.tsx @@ -1,5 +1,5 @@ import React, { useMemo } from 'react'; -import { StyleSheet, View } from 'react-native'; +import { I18nManager, StyleSheet, View } from 'react-native'; import color from 'color'; import { Text } from 'react-native-paper'; @@ -97,7 +97,7 @@ const ReaderAppbar = ({ > = ({ ); const [index, setIndex] = useState(0); + const [isSliderDragging, setIsSliderDragging] = useState(false); + + // AUD-GEST-02: disable ViewPager swipe while a Slider thumb is being dragged + // so a fast horizontal drag is not intercepted as a tab fling that would + // commit a half-dragged value. + useEffect(() => subscribeSliderDragging(setIsSliderDragging), []); const renderTabBar = useCallback( (props: any) => ( @@ -219,7 +227,7 @@ const ReaderBottomSheetV2: React.FC = ({ renderScene={renderScene} onIndexChange={setIndex} initialLayout={{ width: layout.width }} - swipeEnabled + swipeEnabled={!isSliderDragging} style={styles(uiScale).tabView} /> diff --git a/src/screens/reader/components/ReaderSearchbar.tsx b/src/screens/reader/components/ReaderSearchbar.tsx index 32f45c299e..9fbf91bec2 100644 --- a/src/screens/reader/components/ReaderSearchbar.tsx +++ b/src/screens/reader/components/ReaderSearchbar.tsx @@ -1,13 +1,36 @@ -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; -import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + I18nManager, + Keyboard, + Pressable, + StatusBar, + StyleSheet, + Text, + TextInput, +} from 'react-native'; import color from 'color'; import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons'; +import Animated, { + Easing, + ReduceMotion, + withTiming, +} from 'react-native-reanimated'; import { ThemeColors } from '@theme/types'; import { useScaledDimensions } from '@hooks/useScaledDimensions'; +import { useAppSettings } from '@hooks/persisted'; +import { scaleDimension } from '@theme/scaling'; import { getString } from '@strings/translations'; import { ReaderSearchResult } from '../types'; +const fastOutSlowIn = Easing.bezier(0.4, 0.0, 0.2, 1.0); + const MIN_QUERY_LENGTH = 3; interface ReaderSearchbarProps { @@ -17,6 +40,7 @@ interface ReaderSearchbarProps { onNext: () => void; onPrevious: () => void; onClose: () => void; + statusBarHeight?: number; } const ReaderSearchbar = ({ @@ -26,18 +50,57 @@ const ReaderSearchbar = ({ onNext, onPrevious, onClose, + statusBarHeight, }: ReaderSearchbarProps) => { const scaledDimensions = useScaledDimensions(); + const { uiScale = 1.0 } = useAppSettings(); + const effectiveStatusBarHeight = + statusBarHeight || StatusBar.currentHeight || 0; + + const entering = () => { + 'worklet'; + return { + initialValues: { originY: -effectiveStatusBarHeight, opacity: 0 }, + animations: { + originY: withTiming(0, { + duration: 250, + easing: fastOutSlowIn, + reduceMotion: ReduceMotion.System, + }), + opacity: withTiming(1, { duration: 150 }), + }, + }; + }; + const exiting = () => { + 'worklet'; + return { + initialValues: { originY: 0, opacity: 1 }, + animations: { + originY: withTiming(-effectiveStatusBarHeight, { + duration: 250, + easing: fastOutSlowIn, + reduceMotion: ReduceMotion.System, + }), + opacity: withTiming(0, { duration: 150 }), + }, + }; + }; const styles = useMemo( () => StyleSheet.create({ container: { + position: 'absolute', + top: 0, + width: '100%', + zIndex: 2, backgroundColor: color(theme.surface).alpha(0.95).string(), flexDirection: 'row', alignItems: 'center', paddingHorizontal: scaledDimensions.padding.sm, - paddingVertical: scaledDimensions.padding.xs, + paddingTop: effectiveStatusBarHeight + scaledDimensions.padding.xs, + paddingBottom: scaledDimensions.padding.sm, + elevation: 4, }, input: { flex: 1, @@ -46,7 +109,7 @@ const ReaderSearchbar = ({ borderRadius: scaledDimensions.borderRadius.md, paddingHorizontal: scaledDimensions.padding.md, color: theme.onSurface, - fontSize: 16, + fontSize: scaleDimension(16, uiScale), }, button: { paddingHorizontal: scaledDimensions.padding.xs, @@ -56,13 +119,20 @@ const ReaderSearchbar = ({ }, counter: { color: theme.onSurfaceVariant, - fontSize: 13, - minWidth: 32, + fontSize: scaleDimension(13, uiScale), + minWidth: scaleDimension(32, uiScale), + textAlign: 'center', + marginHorizontal: 2, + }, + truncatedCounter: { + color: theme.onSurfaceVariant, + fontSize: scaleDimension(11, uiScale), + minWidth: 0, textAlign: 'center', marginHorizontal: 2, }, }), - [theme, scaledDimensions], + [theme, scaledDimensions, uiScale, effectiveStatusBarHeight], ); const debounceRef = useRef | null>(null); @@ -73,24 +143,64 @@ const ReaderSearchbar = ({ if (debounceRef.current) clearTimeout(debounceRef.current); }; }, []); + + const [inputValue, setInputValue] = useState(searchResult.query); + useEffect(() => { + // Sync from parent only when not actively typing (no pending debounce) or on clear + if (searchResult.query === '' || !debounceRef.current) { + if (searchResult.query !== inputValue) setInputValue(searchResult.query); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchResult.query]); + const handleTextChange = useCallback((text: string) => { + setInputValue(text); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => onSearchRef.current(text), 150); }, []); - const { query, current, total } = searchResult; + const handleClearInput = useCallback(() => { + setInputValue(''); + if (debounceRef.current) clearTimeout(debounceRef.current); + onSearchRef.current(''); + }, []); + + const handleClose = useCallback(() => { + Keyboard.dismiss(); + onClose(); + }, [onClose]); + + const { current, total, renderedTotal, isTruncated } = searchResult; + const effectiveQuery = inputValue; const hasResults = total > 0; - const hasQuery = query.length > 0; + const hasQuery = effectiveQuery.length > 0; const tooShort = hasQuery && - query.length < MIN_QUERY_LENGTH && - !/[^\p{L}\p{N}\s]/u.test(query); + effectiveQuery.length < MIN_QUERY_LENGTH && + !/[^\p{L}\p{N}\s]/u.test(effectiveQuery); + + // Truncation: WebView caps highlights at MAX_RENDERED_MATCHES (1500). Show + // "current/rendered+" so user knows more matches exist unhighlighted. + const counterText = hasResults + ? isTruncated + ? `${current}/${renderedTotal}+` + : `${current}/${total}` + : `0/0`; return ( - - + + @@ -99,41 +209,62 @@ const ReaderSearchbar = ({ style={styles.input} placeholder={getString('readerScreen.searchPlaceholder')} placeholderTextColor={theme.onSurfaceVariant} - value={query} + value={inputValue} onChangeText={handleTextChange} - onSubmitEditing={() => onNext()} + onSubmitEditing={() => { + if (hasResults) onNext(); + }} returnKeyType="search" autoFocus selectionColor={theme.primary} /> - {hasResults && ( + {inputValue.length > 0 && ( + + + + )} + {hasQuery && !tooShort && ( <> = total} + disabled={!hasResults} + accessibilityLabel={getString('readerScreen.search.nextMatch')} + accessibilityRole="button" > = total ? theme.onSurfaceVariant : theme.onSurface - } + color={!hasResults ? theme.onSurfaceVariant : theme.onSurface} /> - - {`${current}/${total}`} + + {counterText} )} @@ -144,7 +275,12 @@ const ReaderSearchbar = ({ })} )} - + {isTruncated && hasResults && ( + + {getString('readerScreen.search.totalMatches', { count: total })} + + )} + ); }; diff --git a/src/screens/reader/components/WebViewReader.tsx b/src/screens/reader/components/WebViewReader.tsx index 11d1d91a7d..7c32a2e60e 100644 --- a/src/screens/reader/components/WebViewReader.tsx +++ b/src/screens/reader/components/WebViewReader.tsx @@ -95,6 +95,7 @@ type WebViewReaderProps = { onPress(): void; onSearchResult?(result: import('../types').ReaderSearchResult): void; searchQuery?: string; + isSearchActive?: boolean; }; const { RNDeviceInfo } = NativeModules; @@ -153,6 +154,7 @@ const WebViewReaderRefactored: React.FC = ({ onPress, onSearchResult, searchQuery = '', + isSearchActive = false, }) => { const { novel, @@ -176,6 +178,10 @@ const WebViewReaderRefactored: React.FC = ({ const webViewNonceRef = useRef(createWebViewNonce()); const allowMessageRef = useRef(createMessageRateLimiter()); + const isSearchActiveRef = useRef(isSearchActive); + useEffect(() => { + isSearchActiveRef.current = isSearchActive; + }, [isSearchActive]); // Chapter transition state for invisible reload const [isTransitioning, setIsTransitioning] = useState(false); @@ -427,7 +433,9 @@ const WebViewReaderRefactored: React.FC = ({ }); // ============================================================================ - // Reading time tracking (PRD 3.2) — manual reading only, pauses during TTS PLAYING + // Reading time tracking (PRD 3.2) — dual-mode: manual pauses during TTS + // PLAYING; TTS stretches are reconciled against the native speaking clock + // so background listening survives Doze (see useTimeTracking). // ============================================================================ const { readingTimeTrackingEnabled, readingTimeInactivityTimeoutMs } = useAppSettings(); @@ -530,6 +538,14 @@ const WebViewReaderRefactored: React.FC = ({ // ============================================================================ // AppState: flush reading progress on background (non-TTS reading) + // AUD-PERS-02: Non-TTS reading has no ForegroundService. On Android 11+ + // the WebView→Bridge→Hermes→SQLite chain can be frozen/killed before + // db.runAsync() completes. MMKV is synchronous (C++ JSI) and survives, + // so paragraph index is durable even if the DB write is lost; the next + // foreground will reconcile via Math.max(mmkv, db). A ForegroundService + // would be required to fully guarantee DB delivery — out of scope here. + // The flush below is best-effort plus the synchronous MMKV path in + // useChapter.saveProgress. // ============================================================================ const isTTSReadingRef = useRef(false); @@ -997,6 +1013,25 @@ const WebViewReaderRefactored: React.FC = ({ break; } + // Search UX: do not mutate last-read while the search overlay is open — anchor is restored on close. + // Exception: TTS progress (paragraph-indexed) must not be dropped, and a "Stay Here" save + // that carries paragraphIndex should also be preserved even if isSearchActive is still true + // due to async React state propagation. + if (isSearchActiveRef.current) { + const isTtsOrParagraphSave = + typeof event.paragraphIndex === 'number'; + if (!isTtsOrParagraphSave) { + readerLog.debug('save-ignore-while-searching'); + break; + } + // Paragraph-indexed saves are allowed during search; they are + // authoritative (TTS or explicit Stay Here). Log but proceed. + readerLog.debug( + 'save-allow-during-search-paragraph', + String(event.paragraphIndex), + ); + } + const savePercent = typeof event.data === 'number' ? event.data diff --git a/src/screens/reader/components/__tests__/ReaderSearchbar.test.tsx b/src/screens/reader/components/__tests__/ReaderSearchbar.test.tsx index ffbc24adf6..b8a8cfb1e1 100644 --- a/src/screens/reader/components/__tests__/ReaderSearchbar.test.tsx +++ b/src/screens/reader/components/__tests__/ReaderSearchbar.test.tsx @@ -17,6 +17,9 @@ jest.mock('@hooks/useScaledDimensions', () => ({ borderRadius: { md: 8 }, }), })); +jest.mock('@hooks/persisted', () => ({ + useAppSettings: () => ({ uiScale: 1.0 }), +})); jest.mock('@strings/translations', () => ({ getString: jest.fn((k: string) => k), diff --git a/src/screens/reader/components/__tests__/WebViewReader.backgroundFlush.test.tsx b/src/screens/reader/components/__tests__/WebViewReader.backgroundFlush.test.tsx index ba630f6da0..37412f676e 100644 --- a/src/screens/reader/components/__tests__/WebViewReader.backgroundFlush.test.tsx +++ b/src/screens/reader/components/__tests__/WebViewReader.backgroundFlush.test.tsx @@ -242,7 +242,7 @@ describe('core.js flushPendingProgressSave guards', () => { saveProgress: jest.fn(), flushPendingProgressSave(this: any) { if ((global as any).window?.tts?.reading) return; - if (!this.hasPerformedInitialScroll && this.suppressSaveOnScroll) { + if (!this.hasPerformedInitialScroll || this.suppressSaveOnScroll) { return; } if (this.scrollDebounceTimer) { @@ -280,12 +280,15 @@ describe('core.js flushPendingProgressSave guards', () => { expect(r.saveProgress).not.toHaveBeenCalled(); }); - it('skips stale 0% during initial suppressSaveOnScroll', () => { - const r: any = makeReader({ - hasPerformedInitialScroll: false, - suppressSaveOnScroll: true, - }); - r.flushPendingProgressSave(); - expect(r.saveProgress).not.toHaveBeenCalled(); - }); + it.each([ + { hasPerformedInitialScroll: false, suppressSaveOnScroll: true }, + { hasPerformedInitialScroll: false, suppressSaveOnScroll: false }, + ])( + 'skips stale 0% before initial scroll ($hasPerformedInitialScroll/$suppressSaveOnScroll)', + overrides => { + const r: any = makeReader(overrides); + r.flushPendingProgressSave(); + expect(r.saveProgress).not.toHaveBeenCalled(); + }, + ); }); diff --git a/src/screens/reader/hooks/useChapter.ts b/src/screens/reader/hooks/useChapter.ts index 82e4cf689e..86629506a7 100644 --- a/src/screens/reader/hooks/useChapter.ts +++ b/src/screens/reader/hooks/useChapter.ts @@ -214,14 +214,28 @@ export default function useChapter( const saveProgress = useCallback( (percentage: number, paragraphIndex?: number, ttsState?: string) => { if (!incognitoMode) { - updateChapterProgress(chapter.id, percentage > 100 ? 100 : percentage); - + // AUD-PERS-02/03: MMKV is synchronous (JSI) and survives process + // freeze; keep it first. DB writes are async via Hermes→SQLite and + // can be lost if Android freezes the process mid-backgrounding. The + // AppState WebView flush in WebViewReader + this direct DB write + // provide a best-effort dual path; a ForegroundService would be + // required to fully guarantee delivery, which is out of scope here. if (paragraphIndex !== undefined) { - MMKVStorage.set(`chapter_progress_${chapter.id}`, paragraphIndex); + try { + MMKVStorage.set(`chapter_progress_${chapter.id}`, paragraphIndex); + } catch { + // ignore MMKV write failure + } } + // Persist progress to DB (via useNovel wrapper which now has .catch). + // Wrapper is void-returning; underlying _updateChapterProgress has + // rejection handling there. Keep MMKV first as the synchronous fall-back + // that survives process freeze (AUD-PERS-02). + updateChapterProgress(chapter.id, percentage > 100 ? 100 : percentage); + if (ttsState) { - updateChapterTTSState(chapter.id, ttsState); + updateChapterTTSState(chapter.id, ttsState).catch(() => {}); } if (percentage >= 97) { diff --git a/src/screens/reader/hooks/useTTSController.ts b/src/screens/reader/hooks/useTTSController.ts index 83f8530181..0712d55ac7 100644 --- a/src/screens/reader/hooks/useTTSController.ts +++ b/src/screens/reader/hooks/useTTSController.ts @@ -2134,23 +2134,16 @@ export function useTTSController( 'media-nav-next', `5 paragraphs reached after NEXT, marking chapter ${sourceChapterId} as 100%`, ); - updateChapterProgressDb(sourceChapterId, 100); + // AUD-PERS-03: attach rejection handler to fire-and-forget DB write + updateChapterProgressDb(sourceChapterId, 100).catch(() => {}); } else if (direction === 'PREV') { ttsCtrlLog.debug( 'media-nav-prev', `5 paragraphs reached after PREV, marking chapter ${sourceChapterId} as in-progress`, ); - try { - updateChapterProgressDb(sourceChapterId, 1); - } catch (e) { - ttsCtrlLog.warn( - 'mark-in-progress-failed', - 'Failed to mark source chapter in-progress', - e, - ); - } + updateChapterProgressDb(sourceChapterId, 1).catch(() => {}); } else { - updateChapterProgressDb(sourceChapterId, 100); + updateChapterProgressDb(sourceChapterId, 100).catch(() => {}); } // FIX: Clear refs AFTER confirmation (moved from useChapterTransition) diff --git a/src/screens/settings/SettingsAppearanceScreen/LanguagePickerModal.tsx b/src/screens/settings/SettingsAppearanceScreen/LanguagePickerModal.tsx index c860d5e718..97ae99b38a 100644 --- a/src/screens/settings/SettingsAppearanceScreen/LanguagePickerModal.tsx +++ b/src/screens/settings/SettingsAppearanceScreen/LanguagePickerModal.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Dialog, Portal } from 'react-native-paper'; -import { StyleSheet } from 'react-native'; +import { Alert, I18nManager, StyleSheet } from 'react-native'; import AppText from '@components/AppText'; import { useTheme } from '@hooks/persisted'; @@ -82,6 +82,26 @@ const LanguagePickerModal: React.FC = ({ const handleLanguageSelect = (locale: string) => { setLocale(locale); onDismiss(); + + const isRtlLocale = [ + 'ar', + 'he', + 'fa', + 'ur', + 'ps', + 'sd', + 'ug', + 'yi', + 'ckb', + 'dv', + ].includes(locale.split(/[-_]/)[0].toLowerCase()); + I18nManager.allowRTL(true); + I18nManager.forceRTL(isRtlLocale); + Alert.alert( + getString('appearanceScreen.appLanguage'), + getString('appearanceScreen.languagePickerModal.restartNote'), + [{ text: getString('common.ok') }], + ); }; return ( diff --git a/src/screens/settings/SettingsReaderScreen/Modals/SearchReturnBehaviorModal.tsx b/src/screens/settings/SettingsReaderScreen/Modals/SearchReturnBehaviorModal.tsx new file mode 100644 index 0000000000..a8f9e5f367 --- /dev/null +++ b/src/screens/settings/SettingsReaderScreen/Modals/SearchReturnBehaviorModal.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { StyleSheet } from 'react-native'; +import { Portal } from 'react-native-paper'; +import { Modal } from '@components'; +import { RadioButton } from '@components/RadioButton/RadioButton'; +import { useAppSettings, useTheme } from '@hooks/persisted'; +import { scaleDimension } from '@theme/scaling'; + +import { getString } from '@strings/translations'; + +type SearchReturnBehavior = 'countdown' | 'immediate' | 'stay'; + +interface Props { + visible: boolean; + onDismiss: () => void; + currentValue: SearchReturnBehavior; + onSelect: (value: SearchReturnBehavior) => void; +} + +const SearchReturnBehaviorModal: React.FC = ({ + visible, + onDismiss, + currentValue, + onSelect, +}) => { + const theme = useTheme(); + const { uiScale = 1.0 } = useAppSettings(); + + const options: { + label: string; + description: string; + value: SearchReturnBehavior; + }[] = [ + { + label: getString('readerSettings.searchReturnBehavior.countdown'), + description: getString( + 'readerSettings.searchReturnBehavior.countdownDesc', + ), + value: 'countdown', + }, + { + label: getString('readerSettings.searchReturnBehavior.immediate'), + description: getString( + 'readerSettings.searchReturnBehavior.immediateDesc', + ), + value: 'immediate', + }, + { + label: getString('readerSettings.searchReturnBehavior.stay'), + description: getString('readerSettings.searchReturnBehavior.stayDesc'), + value: 'stay', + }, + ]; + + const styles = React.useMemo( + () => + StyleSheet.create({ + containerStyle: { paddingBottom: scaleDimension(16, uiScale) }, + }), + [uiScale], + ); + + return ( + + + {options.map(option => ( + { + onSelect(option.value); + onDismiss(); + }} + label={`${option.label} — ${option.description}`} + theme={theme} + labelStyle={{ fontSize: scaleDimension(14, uiScale) }} + /> + ))} + + + ); +}; + +export default SearchReturnBehaviorModal; diff --git a/src/screens/settings/SettingsReaderScreen/tabs/NavigationTab.tsx b/src/screens/settings/SettingsReaderScreen/tabs/NavigationTab.tsx index 8de3a8091c..8a1983498f 100644 --- a/src/screens/settings/SettingsReaderScreen/tabs/NavigationTab.tsx +++ b/src/screens/settings/SettingsReaderScreen/tabs/NavigationTab.tsx @@ -17,6 +17,7 @@ import ContinuousScrollingModal from '../Modals/ContinuousScrollingModal'; import ChapterBoundaryModal from '../Modals/ChapterBoundaryModal'; import TransitionThresholdModal from '../Modals/TransitionThresholdModal'; import StitchThresholdModal from '../Modals/StitchThresholdModal'; +import SearchReturnBehaviorModal from '../Modals/SearchReturnBehaviorModal'; const NavigationTab: React.FC = () => { const theme = useTheme(); @@ -35,6 +36,7 @@ const NavigationTab: React.FC = () => { continuousScrollBoundary = 'bordered', continuousScrollTransitionThreshold = 15, continuousScrollStitchThreshold = 90, + searchReturnBehavior = 'countdown', setChapterGeneralSettings, } = useChapterGeneralSettings(); @@ -69,6 +71,12 @@ const NavigationTab: React.FC = () => { setFalse: hideStitchThresholdModal, } = useBoolean(); + const { + value: searchReturnModalVisible, + setTrue: showSearchReturnModal, + setFalse: hideSearchReturnModal, + } = useBoolean(); + const styles = React.useMemo( () => StyleSheet.create({ @@ -234,6 +242,28 @@ const NavigationTab: React.FC = () => { )} + + + {getString('readerSettings.searchReturnBehavior.header')} + + + + + {getString('readerScreen.bottomSheet.autoscroll')} @@ -337,6 +367,16 @@ const NavigationTab: React.FC = () => { }); }} /> + + setChapterGeneralSettings({ searchReturnBehavior: value }) + } + /> ); }; diff --git a/src/services/updates/index.ts b/src/services/updates/index.ts index ff2c005289..d04be70c26 100644 --- a/src/services/updates/index.ts +++ b/src/services/updates/index.ts @@ -67,9 +67,13 @@ const updateLibrary = async ( ) as LibraryNovelInfo[]; } - if (libraryNovels.length > 0) { + // Only a full-library update advances the scheduler's global timestamp. + // Category updates must not postpone scheduled updates for the rest of the library. + if (!categoryId) { MMKVStorage.set(LAST_UPDATE_TIME, dayjs().format('YYYY-MM-DD HH:mm:ss')); + } + if (libraryNovels.length > 0) { const sourceQueues = groupNovelsByPlugin(libraryNovels); const activeNovels = new Map(); let completedNovels = 0; diff --git a/strings/languages/en/strings.json b/strings/languages/en/strings.json index fe926406dc..862a08073d 100644 --- a/strings/languages/en/strings.json +++ b/strings/languages/en/strings.json @@ -611,9 +611,30 @@ "noNextChapter": "There's no next chapter", "noPreviousChapter": "There's no previous chapter", "searchPlaceholder": "Search chapter", - "searchMinLength": "Enter at least %{count} characters to search" + "searchMinLength": "Enter at least %{count} characters to search", + "search": { + "returnBannerCountdown": "Returning to last read position in %{seconds}s", + "returnNow": "Return now", + "stayHere": "Stay here", + "closeSearch": "Close search", + "clearSearch": "Clear search", + "previousMatch": "Previous match", + "nextMatch": "Next match", + "totalMatches": "(%{count} total)" + } }, "readerSettings": { + "searchReturnBehavior": { + "header": "In-chapter search", + "info": "Search never changes your saved position while open. On close, choose what happens to the scroll position. Default is a 5s countdown that auto-returns to where you were before searching.", + "title": "On close: return to last read", + "countdown": "Countdown 5s then return", + "immediate": "Return immediately", + "stay": "Stay where search left you", + "countdownDesc": "Show banner and auto-return after 5s", + "immediateDesc": "Jump back without countdown", + "stayDesc": "Never auto-return" + }, "autoScrollInterval": "Scroll interval (seconds)", "autoScrollOffset": "Scroll offset (screen heights)", "backgroundColor": "Background color", @@ -666,6 +687,7 @@ "days": "days", "hours": "hours", "mins": "mins", + "secs": "secs", "total": "total", "other": "Other", "tabs": { diff --git a/strings/types/index.ts b/strings/types/index.ts index 1ac1795e11..119fed5af0 100644 --- a/strings/types/index.ts +++ b/strings/types/index.ts @@ -523,6 +523,23 @@ export interface StringMap { 'readerScreen.noPreviousChapter': 'string'; 'readerScreen.searchPlaceholder': 'string'; 'readerScreen.searchMinLength': 'string'; + 'readerScreen.search.returnBannerCountdown': 'string'; + 'readerScreen.search.returnNow': 'string'; + 'readerScreen.search.stayHere': 'string'; + 'readerScreen.search.closeSearch': 'string'; + 'readerScreen.search.clearSearch': 'string'; + 'readerScreen.search.previousMatch': 'string'; + 'readerScreen.search.nextMatch': 'string'; + 'readerScreen.search.totalMatches': 'string'; + 'readerSettings.searchReturnBehavior.header': 'string'; + 'readerSettings.searchReturnBehavior.info': 'string'; + 'readerSettings.searchReturnBehavior.title': 'string'; + 'readerSettings.searchReturnBehavior.countdown': 'string'; + 'readerSettings.searchReturnBehavior.immediate': 'string'; + 'readerSettings.searchReturnBehavior.stay': 'string'; + 'readerSettings.searchReturnBehavior.countdownDesc': 'string'; + 'readerSettings.searchReturnBehavior.immediateDesc': 'string'; + 'readerSettings.searchReturnBehavior.stayDesc': 'string'; 'readerSettings.autoScrollInterval': 'string'; 'readerSettings.autoScrollOffset': 'string'; 'readerSettings.backgroundColor': 'string'; @@ -571,6 +588,7 @@ export interface StringMap { 'statsScreen.days': 'string'; 'statsScreen.hours': 'string'; 'statsScreen.mins': 'string'; + 'statsScreen.secs': 'string'; 'statsScreen.total': 'string'; 'statsScreen.other': 'string'; 'statsScreen.tabs.overview': 'string';