Compare commits

...

77 Commits

Author SHA1 Message Date
Rajarshee Chatterjee ae8d055689 perf: Limit Chapter Neighbor Queries To One Row
Lint / ESLint (push) Has been cancelled
Tests / Jest (push) Has been cancelled
Type Check / TypeScript (push) Has been cancelled
2026-09-26 20:43:28 +05:30
trietng 1607f80a0a feat: Add Volume Button Navigation and Slide Animation Settings for Paged Reading (#2058)
* feat: volume navigation and disable animation settings for paged reading

* fix: transition style removal

* fix: pageReader incorrect initialization

* fix: remove unneeded check

* chore: update Vietnamese translations for paged reading settings
2026-09-26 20:40:43 +05:30
github-actions[bot] a0c20e209c chore: Release v2.1.4 2026-09-26 07:14:58 +00:00
Rajarshee Chatterjee 648477931b feat: Add Version and Build Information to Error Fallback 2026-09-26 11:05:34 +05:30
Palloxin e42e8f66f2 fix: Improve Code Editor Newline Regex (#2021)
* Code Editor, New Lines regex improvement

* Update SimpleCodeEditor.tsx
2026-09-25 11:15:13 +05:30
Palloxin de5358727a fix: Restore Broken Preview to Previous State (#2004)
* Fix broken preview

* Optimized base64 image (reduced)

* fix image base64
2026-09-25 11:06:24 +05:30
Rajarshee Chatterjee 706459783f feat: Add Optional Continue Reading Buttons To Library 2026-09-23 18:55:51 +05:30
Patrick Loser 1ca9a679cf perf: Optimize Background Task Memory Usage and Lifecycle Management (#2059)
* fix: bound background task memory usage

* Fix background task cancellation and updates

* Update TaskActionReceiver.kt
2026-09-23 18:41:50 +05:30
Rajarshee Chatterjee 920f88aee4 fix: Resolve Jest CI Failures 2026-09-23 12:08:49 +05:30
Rajarshee Chatterjee cef3ae5bdf feat: Polish Statistics Screen Layout 2026-09-23 11:53:50 +05:30
Rajarshee Chatterjee 39c788e576 feat: Improve Genre Taxonomy Screen UX 2026-09-23 11:26:21 +05:30
Rajarshee Chatterjee d40713ac99 feat: Improve Custom Code UX 2026-09-23 10:41:38 +05:30
Rajarshee Chatterjee f53bed7190 chore: Update Translations (#1981)
* New translations strings.json (Romanian)

[ci skip]
[ci skip]

* New translations strings.json (French)

[ci skip]
[ci skip]

* New translations strings.json (Spanish)

[ci skip]
[ci skip]

* New translations strings.json (Afrikaans)

[ci skip]
[ci skip]

* New translations strings.json (Arabic)

[ci skip]
[ci skip]

* New translations strings.json (Catalan)

[ci skip]
[ci skip]

* New translations strings.json (Czech)

[ci skip]
[ci skip]

* New translations strings.json (Danish)

[ci skip]
[ci skip]

* New translations strings.json (German)

[ci skip]
[ci skip]

* New translations strings.json (Greek)

[ci skip]
[ci skip]

* New translations strings.json (Finnish)

[ci skip]
[ci skip]

* New translations strings.json (Hebrew)

[ci skip]
[ci skip]

* New translations strings.json (Hungarian)

[ci skip]
[ci skip]

* New translations strings.json (Italian)

[ci skip]
[ci skip]

* New translations strings.json (Japanese)

[ci skip]
[ci skip]

* New translations strings.json (Korean)

[ci skip]
[ci skip]

* New translations strings.json (Dutch)

[ci skip]
[ci skip]

* New translations strings.json (Norwegian)

[ci skip]
[ci skip]

* New translations strings.json (Polish)

[ci skip]
[ci skip]

* New translations strings.json (Portuguese)

[ci skip]
[ci skip]

* New translations strings.json (Russian)

[ci skip]
[ci skip]

* New translations strings.json (Albanian)

[ci skip]
[ci skip]

* New translations strings.json (Serbian (Cyrillic))

[ci skip]
[ci skip]

* New translations strings.json (Swedish)

[ci skip]
[ci skip]

* New translations strings.json (Turkish)

[ci skip]
[ci skip]

* New translations strings.json (Ukrainian)

[ci skip]
[ci skip]

* New translations strings.json (Chinese Simplified)

[ci skip]
[ci skip]

* New translations strings.json (Chinese Traditional)

[ci skip]
[ci skip]

* New translations strings.json (Vietnamese)

[ci skip]
[ci skip]

* New translations strings.json (Portuguese, Brazilian)

[ci skip]
[ci skip]

* New translations strings.json (Indonesian)

[ci skip]
[ci skip]

* New translations strings.json (Hindi)

[ci skip]
[ci skip]

* New translations strings.json (Assamese)

[ci skip]
[ci skip]

* New translations strings.json (Odia)

[ci skip]
[ci skip]

* New translations strings.json (Romanian)

[ci skip]
[ci skip]

* New translations strings.json (French)

[ci skip]
[ci skip]

* New translations strings.json (Spanish)

[ci skip]
[ci skip]

* New translations strings.json (Afrikaans)

[ci skip]
[ci skip]

* New translations strings.json (Arabic)

[ci skip]
[ci skip]

* New translations strings.json (Catalan)

[ci skip]
[ci skip]

* New translations strings.json (Czech)

[ci skip]
[ci skip]

* New translations strings.json (Danish)

[ci skip]
[ci skip]

* New translations strings.json (German)

[ci skip]
[ci skip]

* New translations strings.json (Greek)

[ci skip]
[ci skip]

* New translations strings.json (Finnish)

[ci skip]
[ci skip]

* New translations strings.json (Hebrew)

[ci skip]
[ci skip]

* New translations strings.json (Hungarian)

[ci skip]
[ci skip]

* New translations strings.json (Italian)

[ci skip]
[ci skip]

* New translations strings.json (Japanese)

[ci skip]
[ci skip]

* New translations strings.json (Korean)

[ci skip]
[ci skip]

* New translations strings.json (Dutch)

[ci skip]
[ci skip]

* New translations strings.json (Norwegian)

[ci skip]
[ci skip]

* New translations strings.json (Polish)

[ci skip]
[ci skip]

* New translations strings.json (Portuguese)

[ci skip]
[ci skip]

* New translations strings.json (Russian)

[ci skip]
[ci skip]

* New translations strings.json (Albanian)

[ci skip]
[ci skip]

* New translations strings.json (Serbian (Cyrillic))

[ci skip]
[ci skip]

* New translations strings.json (Swedish)

[ci skip]
[ci skip]

* New translations strings.json (Turkish)

[ci skip]
[ci skip]

* New translations strings.json (Ukrainian)

[ci skip]
[ci skip]

* New translations strings.json (Chinese Simplified)

[ci skip]
[ci skip]

* New translations strings.json (Chinese Traditional)

[ci skip]
[ci skip]

* New translations strings.json (Vietnamese)

[ci skip]
[ci skip]

* New translations strings.json (Portuguese, Brazilian)

[ci skip]
[ci skip]

* New translations strings.json (Indonesian)

[ci skip]
[ci skip]

* New translations strings.json (Hindi)

[ci skip]
[ci skip]

* New translations strings.json (Assamese)

[ci skip]
[ci skip]

* New translations strings.json (Odia)

[ci skip]
[ci skip]

* New translations strings.json (Romanian)

[ci skip]
[ci skip]

* New translations strings.json (French)

[ci skip]
[ci skip]

* New translations strings.json (Spanish)

[ci skip]
[ci skip]

* New translations strings.json (Afrikaans)

[ci skip]
[ci skip]

* New translations strings.json (Arabic)

[ci skip]
[ci skip]

* New translations strings.json (Catalan)

[ci skip]
[ci skip]

* New translations strings.json (Czech)

[ci skip]
[ci skip]

* New translations strings.json (Danish)

[ci skip]
[ci skip]

* New translations strings.json (German)

[ci skip]
[ci skip]

* New translations strings.json (Greek)

[ci skip]
[ci skip]

* New translations strings.json (Finnish)

[ci skip]
[ci skip]

* New translations strings.json (Hebrew)

[ci skip]
[ci skip]

* New translations strings.json (Hungarian)

[ci skip]
[ci skip]

* New translations strings.json (Italian)

[ci skip]
[ci skip]

* New translations strings.json (Japanese)

[ci skip]
[ci skip]

* New translations strings.json (Korean)

[ci skip]
[ci skip]

* New translations strings.json (Dutch)

[ci skip]
[ci skip]

* New translations strings.json (Norwegian)

[ci skip]
[ci skip]

* New translations strings.json (Polish)

[ci skip]
[ci skip]

* New translations strings.json (Portuguese)

[ci skip]
[ci skip]

* New translations strings.json (Russian)

[ci skip]
[ci skip]

* New translations strings.json (Albanian)

[ci skip]
[ci skip]

* New translations strings.json (Serbian (Cyrillic))

[ci skip]
[ci skip]

* New translations strings.json (Swedish)

[ci skip]
[ci skip]

* New translations strings.json (Turkish)

[ci skip]
[ci skip]

* New translations strings.json (Ukrainian)

[ci skip]
[ci skip]

* New translations strings.json (Chinese Simplified)

[ci skip]
[ci skip]

* New translations strings.json (Chinese Traditional)

[ci skip]
[ci skip]

* New translations strings.json (Vietnamese)

[ci skip]
[ci skip]

* New translations strings.json (Portuguese, Brazilian)

[ci skip]
[ci skip]

* New translations strings.json (Indonesian)

[ci skip]
[ci skip]

* New translations strings.json (Hindi)

[ci skip]
[ci skip]

* New translations strings.json (Assamese)

[ci skip]
[ci skip]

* New translations strings.json (Odia)

[ci skip]
[ci skip]

* New translations strings.json (Korean)

[ci skip]
[ci skip]

* New translations strings.json (Korean)

[ci skip]
[ci skip]

* New translations strings.json (Indonesian)

[ci skip]
[ci skip]

* New translations strings.json (Romanian)

[ci skip]
[ci skip]

* New translations strings.json (French)

[ci skip]
[ci skip]

* New translations strings.json (Spanish)

[ci skip]
[ci skip]

* New translations strings.json (Afrikaans)

[ci skip]
[ci skip]

* New translations strings.json (Arabic)

[ci skip]
[ci skip]

* New translations strings.json (Catalan)

[ci skip]
[ci skip]

* New translations strings.json (Czech)

[ci skip]
[ci skip]

* New translations strings.json (Danish)

[ci skip]
[ci skip]

* New translations strings.json (German)

[ci skip]
[ci skip]

* New translations strings.json (Greek)

[ci skip]
[ci skip]

* New translations strings.json (Finnish)

[ci skip]
[ci skip]

* New translations strings.json (Hebrew)

[ci skip]
[ci skip]

* New translations strings.json (Hungarian)

[ci skip]
[ci skip]

* New translations strings.json (Italian)

[ci skip]
[ci skip]

* New translations strings.json (Japanese)

[ci skip]
[ci skip]

* New translations strings.json (Dutch)

[ci skip]
[ci skip]

* New translations strings.json (Norwegian)

[ci skip]
[ci skip]

* New translations strings.json (Polish)

[ci skip]
[ci skip]

* New translations strings.json (Portuguese)

[ci skip]
[ci skip]

* New translations strings.json (Russian)

[ci skip]
[ci skip]

* New translations strings.json (Albanian)

[ci skip]
[ci skip]

* New translations strings.json (Serbian (Cyrillic))

[ci skip]
[ci skip]

* New translations strings.json (Swedish)

[ci skip]
[ci skip]

* New translations strings.json (Turkish)

[ci skip]
[ci skip]

* New translations strings.json (Ukrainian)

[ci skip]
[ci skip]

* New translations strings.json (Chinese Simplified)

[ci skip]
[ci skip]

* New translations strings.json (Chinese Traditional)

[ci skip]
[ci skip]

* New translations strings.json (Vietnamese)

[ci skip]
[ci skip]

* New translations strings.json (Portuguese, Brazilian)

[ci skip]
[ci skip]

* New translations strings.json (Hindi)

[ci skip]
[ci skip]

* New translations strings.json (Assamese)

[ci skip]
[ci skip]

* New translations strings.json (Odia)

[ci skip]
[ci skip]

* New translations strings.json (Korean)

[ci skip]
[ci skip]

* New translations strings.json (Indonesian)

[ci skip]
[ci skip]

* New translations strings.json (Romanian)

[ci skip]
[ci skip]

* New translations strings.json (French)

[ci skip]
[ci skip]

* New translations strings.json (Spanish)

[ci skip]
[ci skip]

* New translations strings.json (Afrikaans)

[ci skip]
[ci skip]

* New translations strings.json (Arabic)

[ci skip]
[ci skip]

* New translations strings.json (Catalan)

[ci skip]
[ci skip]

* New translations strings.json (Czech)

[ci skip]
[ci skip]

* New translations strings.json (Danish)

[ci skip]
[ci skip]

* New translations strings.json (German)

[ci skip]
[ci skip]

* New translations strings.json (Greek)

[ci skip]
[ci skip]

* New translations strings.json (Finnish)

[ci skip]
[ci skip]

* New translations strings.json (Hebrew)

[ci skip]
[ci skip]

* New translations strings.json (Hungarian)

[ci skip]
[ci skip]

* New translations strings.json (Italian)

[ci skip]
[ci skip]

* New translations strings.json (Japanese)

[ci skip]
[ci skip]

* New translations strings.json (Dutch)

[ci skip]
[ci skip]

* New translations strings.json (Norwegian)

[ci skip]
[ci skip]

* New translations strings.json (Polish)

[ci skip]
[ci skip]

* New translations strings.json (Portuguese)

[ci skip]
[ci skip]

* New translations strings.json (Russian)

[ci skip]
[ci skip]

* New translations strings.json (Albanian)

[ci skip]
[ci skip]

* New translations strings.json (Serbian (Cyrillic))

[ci skip]
[ci skip]

* New translations strings.json (Swedish)

[ci skip]
[ci skip]

* New translations strings.json (Turkish)

[ci skip]
[ci skip]

* New translations strings.json (Ukrainian)

[ci skip]
[ci skip]

* New translations strings.json (Chinese Simplified)

[ci skip]
[ci skip]

* New translations strings.json (Chinese Traditional)

[ci skip]
[ci skip]

* New translations strings.json (Vietnamese)

[ci skip]
[ci skip]

* New translations strings.json (Portuguese, Brazilian)

[ci skip]
[ci skip]

* New translations strings.json (Hindi)

[ci skip]
[ci skip]

* New translations strings.json (Assamese)

[ci skip]
[ci skip]

* New translations strings.json (Odia)

[ci skip]
[ci skip]

* New translations strings.json (Swedish)

[ci skip]
[ci skip]

* New translations strings.json (Korean)

[ci skip]
[ci skip]

* New translations strings.json (Indonesian)

[ci skip]
[ci skip]

* New translations strings.json (Romanian)

[ci skip]
[ci skip]

* New translations strings.json (French)

[ci skip]
[ci skip]

* New translations strings.json (Spanish)

[ci skip]
[ci skip]

* New translations strings.json (Afrikaans)

[ci skip]
[ci skip]

* New translations strings.json (Arabic)

[ci skip]
[ci skip]

* New translations strings.json (Catalan)

[ci skip]
[ci skip]

* New translations strings.json (Czech)

[ci skip]
[ci skip]

* New translations strings.json (Danish)

[ci skip]
[ci skip]

* New translations strings.json (German)

[ci skip]
[ci skip]

* New translations strings.json (Greek)

[ci skip]
[ci skip]

* New translations strings.json (Finnish)

[ci skip]
[ci skip]

* New translations strings.json (Hebrew)

[ci skip]
[ci skip]

* New translations strings.json (Hungarian)

[ci skip]
[ci skip]

* New translations strings.json (Italian)

[ci skip]
[ci skip]

* New translations strings.json (Japanese)

[ci skip]
[ci skip]

* New translations strings.json (Dutch)

[ci skip]
[ci skip]

* New translations strings.json (Norwegian)

[ci skip]
[ci skip]

* New translations strings.json (Polish)

[ci skip]
[ci skip]

* New translations strings.json (Portuguese)

[ci skip]
[ci skip]

* New translations strings.json (Russian)

[ci skip]
[ci skip]

* New translations strings.json (Albanian)

[ci skip]
[ci skip]

* New translations strings.json (Serbian (Cyrillic))

[ci skip]
[ci skip]

* New translations strings.json (Swedish)

[ci skip]
[ci skip]

* New translations strings.json (Turkish)

[ci skip]
[ci skip]

* New translations strings.json (Ukrainian)

[ci skip]
[ci skip]

* New translations strings.json (Chinese Simplified)

[ci skip]
[ci skip]

* New translations strings.json (Chinese Traditional)

[ci skip]
[ci skip]

* New translations strings.json (Vietnamese)

[ci skip]
[ci skip]

* New translations strings.json (Portuguese, Brazilian)

[ci skip]
[ci skip]

* New translations strings.json (Hindi)

[ci skip]
[ci skip]

* New translations strings.json (Assamese)

[ci skip]
[ci skip]

* New translations strings.json (Odia)

[ci skip]
[ci skip]

* fix: Restore Overwritten Locale Translations

* New translations strings.json (Romanian)

[ci skip]
[ci skip]

* New translations strings.json (French)

[ci skip]
[ci skip]

* New translations strings.json (Spanish)

[ci skip]
[ci skip]

* New translations strings.json (Afrikaans)

[ci skip]
[ci skip]

* New translations strings.json (Arabic)

[ci skip]
[ci skip]

* New translations strings.json (Catalan)

[ci skip]
[ci skip]

* New translations strings.json (Czech)

[ci skip]
[ci skip]

* New translations strings.json (Korean)

[ci skip]
[ci skip]

* New translations strings.json (Indonesian)

[ci skip]
[ci skip]

* New translations strings.json (Danish)

[ci skip]
[ci skip]

* New translations strings.json (German)

[ci skip]
[ci skip]

* New translations strings.json (Greek)

[ci skip]
[ci skip]

* New translations strings.json (Finnish)

[ci skip]
[ci skip]

* New translations strings.json (Hebrew)

[ci skip]
[ci skip]

* New translations strings.json (Hungarian)

[ci skip]
[ci skip]

* New translations strings.json (Italian)

[ci skip]
[ci skip]

* New translations strings.json (Japanese)

[ci skip]
[ci skip]

* New translations strings.json (Dutch)

[ci skip]
[ci skip]

* New translations strings.json (Norwegian)

[ci skip]
[ci skip]

* New translations strings.json (Polish)

[ci skip]
[ci skip]

* New translations strings.json (Portuguese)

[ci skip]
[ci skip]

* New translations strings.json (Russian)

[ci skip]
[ci skip]

* New translations strings.json (Albanian)

[ci skip]
[ci skip]

* New translations strings.json (Serbian (Cyrillic))

[ci skip]
[ci skip]

* New translations strings.json (Swedish)

[ci skip]
[ci skip]

* New translations strings.json (Turkish)

[ci skip]
[ci skip]

* New translations strings.json (Ukrainian)

[ci skip]
[ci skip]

* New translations strings.json (Chinese Simplified)

[ci skip]
[ci skip]

* New translations strings.json (Chinese Traditional)

[ci skip]
[ci skip]

* New translations strings.json (Vietnamese)

[ci skip]
[ci skip]

* New translations strings.json (Portuguese, Brazilian)

[ci skip]
[ci skip]

* New translations strings.json (Hindi)

[ci skip]
[ci skip]

* New translations strings.json (Assamese)

[ci skip]
[ci skip]

* New translations strings.json (Odia)

[ci skip]
[ci skip]

* New translations strings.json (Spanish)

[ci skip]
[ci skip]

* New translations strings.json (Greek)

[ci skip]
[ci skip]

* New translations strings.json (Russian)

[ci skip]
[ci skip]

* fix: Restore Overwritten Locale Translations
2026-09-22 10:06:03 +05:30
Rajarshee Chatterjee 0b9c415681 fix: Prevent Chapter Drawer Blank Flash On Open (Closes #2038) (#2048)
Opening the reader's chapter drawer flashed an empty panel before the
chapter list appeared, most visibly on slower devices.

Three things combined to cause it:

- The panel carried `backgroundColor: 'transparent'`, which overrode the
  opaque default `react-native-drawer-layout` applies to its own
  `styles.drawer`. Since `drawerStyle` is applied last in the panel's
  style array, the panel had no background at all, so the reader showed
  through it for every frame before the content painted. It now carries
  the drawer's own `theme.surface`, matching what the content already
  paints, so the panel is opaque from the first frame.

- The chapter list was mounted by the same handler that opened the
  drawer, so the slide-in animation (UI thread) ran while the list was
  still mounting and measuring (JS thread). The list is now mounted once
  the chapter has loaded and interactions have settled, before the
  drawer is first opened. A closed panel stays laid out -- it is only
  translated off-screen -- so the list measures and renders its first
  rows out of sight instead of during the animation.

- When the list mounted after the chapters arrived, it was scrolled to
  the current chapter with an animation even though `initialScrollIndex`
  had already placed it there, adding a second visible jump. That scroll
  is now skipped on the initial mount and kept for later chapter
  changes.


Claude-Session: https://claude.ai/code/session_01Et1ihq4SBUhfvxgWJisHTe

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-22 09:15:02 +05:30
Rajarshee Chatterjee 38003c59e0 fix: Stop Bottom Nav Icons Trembling On Tab Press (Closes #2041) (#2047)
The focused tab indicator animated `width` directly, which triggered layout every frame — Yoga's per-node pixel rounding then shifted the icon and label inconsistently as the width swept through fractional values, causing a visible tremble. Fixed by giving the icon a fixed-size box and animating the indicator via `scaleX` on an absolutely positioned sibling instead, since transforms don't trigger layout.
2026-09-22 08:23:58 +05:30
Rajarshee Chatterjee f1119d8c30 feat: Add Always Ask Category Selection (Closes #537) 2026-09-16 10:14:29 +05:30
Rajarshee Chatterjee a5e2b4d4f0 feat: Add Crash Log Collection And Sharing (Closes #11) 2026-09-15 14:16:51 +05:30
Rajarshee Chatterjee a12cba030d fix: Skip Dash-Only TTS Dividers 2026-09-15 13:38:39 +05:30
Rajarshee Chatterjee 56d1140aad chore: Require Issue Templates 2026-09-14 08:44:15 +05:30
Rajarshee Chatterjee 0379141304 feat: Add Individual Task Cancellation (Closes #1985) 2026-09-11 18:41:51 +05:30
Rajarshee Chatterjee b6e9f7157d chore: Rename Feature Request Label 2026-09-08 07:03:28 +05:30
Rajarshee Chatterjee edb3df719f fix: Repair Invalid Default Category Names (Closes #2031) 2026-09-07 22:25:31 +05:30
Rajarshee Chatterjee 9bde3df74d fix: Prevent Novel Screen Empty State Flash (Closes #2034) 2026-09-07 21:13:47 +05:30
Rajarshee Chatterjee 2426e6fe3e chore: Lowercase Issue Template Labels 2026-09-07 20:49:59 +05:30
Rajarshee Chatterjee 93209abebd fix: Make Backup Restore Non-Destructive (Closes #2026) 2026-09-07 20:43:20 +05:30
Claude 158786ccd5 fix: Prevent Crash in parseChapterNumber When Chapter or Novel Name Is Missing
Guards against undefined/null chapterName and novelName so
parseChapterNumber no longer throws "Cannot read property
'toLowerCase' of undefined" when rendering the Updates screen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011WW3AHpNPxoRVsbAMVRX6T
2026-09-02 19:15:46 +00:00
Patrick Loser 77be5624f2 feat: Add URL Novel Search and Intent-Based Novel Opening (#1987)
* Add native share intent handling module

* Handle shared URLs via intent handler

* i18n: add global search and novel error strings

Generated with omp (AI coding agent)

* novel store: track bootstrap failures as user-facing errors

Bootstrap failures no longer surface raw plugin/DB errors; missing novels map to a custom not-found message. Generated with omp (AI coding agent)

* novel screen: render error view when bootstrap fails

Shows a custom error with a back action instead of a blank screen, and hides the appbar actions in the error state. Generated with omp (AI coding agent)

* global search: never auto-navigate or search URL text

URLs are handled by the screen's explicit submit flow; navigation and search stay out of the hook. Generated with omp (AI coding agent)

* global search: add animated open-novel FAB with clipboard offer

Submit or FAB tap opens a matching URL's novel; unmatched URLs toast. The FAB floats above the keyboard via keyboard-controller and slides in from the right. Generated with omp (AI coding agent)

* fix review issues

* fix rebase issue
2026-09-01 18:59:32 +05:30
Rajarshee Chatterjee 4b580c90ef fix: Prevent TTS From Speaking Surrounding Quotes (Closes #2008) 2026-08-29 11:41:59 +05:30
github-actions[bot] cb1a5d9e82 chore: Release v2.1.3 2026-08-22 06:13:17 +00:00
Mohammad 7e20f7dccb feat: Increase Maximum Reader Text Size from 20 to 50 (#1998)
Increased maximum text size limit in Reader component, and settings page
2026-08-22 08:55:41 +05:30
Rajarshee Chatterjee 0fab0143b1 fix: Prevent Large EPUB Stalls And Optimize Imports
Closes #1932
Closes #2005
2026-08-22 08:41:48 +05:30
Rajarshee Chatterjee 5a0717d2c7 fix: Prevent Duplicate TTS Text From Nested Formatting (Closes #1994) 2026-08-18 11:50:06 +05:30
Patrick Loser 09652f9962 fix: Refetch Library After Category Update (#1988)
Add tests covering NovelScreenButtonGroup category assignment
flows to ensure the library is refreshed and the novel state
is updated when needed.
2026-08-18 09:28:07 +05:30
Rajarshee Chatterjee eef58f212f fix: Support Namespace-Prefixed EPUB Imports 2026-08-17 21:16:29 +05:30
Rajarshee Chatterjee 523aa90c19 fix: Include Novel Covers In Library Backups (Closes #1983) 2026-08-16 12:48:54 +05:30
Ahjie521 eb310f7821 feat: Add Empty Chapter Refresh in Reader (#1978)
* feat(reader): swipe down to re-fetch a chapter, never cache empty responses

- pull down at the top of a chapter to re-fetch it, recovering from
  transient empty responses without leaving the reader
- empty responses are evicted from the chapter cache so a refresh
  re-fetches instead of replaying the empty message
- add a refresh button to the reader's bottom bar and a 'Refresh
  chapter' link on the empty-chapter message
- register the typescript-eslint plugin and ignore assets/ in the
  eslint flat config so lint-staged can lint core.js

* feat(reader): move refresh option to appbar menu
2026-08-16 07:36:41 +05:30
Patrick Loser af2a224781 fix: Novel Screen Scroll Lag, Source Pagination Stalls, and Skeleton Overlap (#1971)
* fix

* Update pnpm-lock.yaml

* fix tests
2026-08-16 07:34:17 +05:30
Rajarshee Chatterjee 990cd4f2eb fix: Upgrade WorkManager for Repeated Library Updates (Closes #1972) 2026-08-11 15:58:51 +05:30
Rajarshee Chatterjee 3ac611f633 fix: Normalize EPUB SVG Images in Nitro Module (Closes #1622) 2026-08-11 14:51:50 +05:30
Rajarshee Chatterjee 909504a721 feat: Add Repository Enable And Disable Controls (Closes #1628) 2026-08-11 13:50:25 +05:30
Rajarshee Chatterjee 63349de1bb fix: Select All Chapters Across Lazy-Loaded Batches (Closes #1960) 2026-08-11 11:58:18 +05:30
Rajarshee Chatterjee c6679b7f41 fix: Draw Content Behind Android Navigation Bar (Closes #1260) 2026-08-11 11:53:03 +05:30
Ahjie521 15560b67b1 fix: Pass Plugin imageRequestInit Headers to Novel Detail Cover (#1977)
The novel detail header rendered its cover with a bare { uri } source,
so plugins whose cover CDN requires request headers (e.g. RanobeLib's
cover.cdnlibs.org, which 403s without a Referer) showed no cover on the
novel detail screen even though covers worked in browse/library lists.

Mirror the LibraryListView pattern: fetch getPlugin(novel.pluginId)?
.imageRequestInit and attach its headers to the cover source.
2026-08-11 08:26:12 +05:30
Alvan Noah 7883b28cd9 fix: Stop TTS During Incoming and Outgoing Phone Calls (#1976)
fix: TTS continues playing during incoming and outgoing phone calls

Implement audio focus handling for TTS playback on Android, including support for request and abandonment during playback and transient interruptions.

Closes #1975
2026-08-11 08:01:55 +05:30
Patrick Loser 64707409bb feat: Create Dedicated Custom Code Settings Page V2 (#1902)
* init

* text modification without webview rerender

* Fix SettingsReaderScreen

* remove unneeded

* solve ts issue

* add tests

* Add KeyboardProvider

* Update SimpleCodeEditor.tsx

* fix ts error

* add types package

* reworked SimpleCodeEditor

* Preserve highlightmode

* Update SimpleCodeEditor.tsx

* performance improvement

* simplified

* fix tsc

* fix alignment issues

* load current snippet into example view

* Merge branch 'master' into custom-code-clean

* fix type + lint

* Resolve can't read property id of undefined error

---------

Co-authored-by: Rajarshee Chatterjee <rajarshee.adm@gmail.com>
2026-08-11 07:09:52 +05:30
Rajarshee Chatterjee 7f1f76408b docs: Add AGENTS.md with Agent Setup, Commands, and Conventions (#1974) 2026-08-10 09:17:43 +05:30
Rajarshee Chatterjee 51560195ba fix: Soften Skeleton Loading Colors (Closes #1964) 2026-08-10 08:25:10 +05:30
Rajarshee Chatterjee 084dcccabc fix: Match Chapter Drawer Read Color (Closes #1973)
[skip ci]
2026-08-10 08:17:22 +05:30
Rajarshee Chatterjee 3ad6e372fd fix: Correct Translation Strings Across Locales 2026-08-10 08:16:32 +05:30
Ali 179feb56ee fix: Include All Chapters Beyond the 1000-Row UI Limit (#1938)
* chapter backup and restore fix

* Update .gitignore

Co-authored-by: Rajarshee Chatterjee <rajarshee.adm@gmail.com>

* test: update backup mocks for getAllNovelChaptersForBackup

---------

Co-authored-by: Rajarshee Chatterjee <rajarshee.adm@gmail.com>
2026-08-09 16:28:12 +05:30
Patrick Loser 67e01bc2df fix: Resolve Stats Screen Logic Issue (#1968)
* fix genre distribution

* add tests

* open edit dialog when creatig new taxonomy category
2026-08-08 16:28:12 +05:30
Patrick Loser 3ece098b94 perf: Optimize APK Size (#1969)
* optimize android build

* update lodash imports

* remove react-navigation/stack

* remove flash-list
2026-08-08 16:24:07 +05:30
Patrick Loser 586e085141 fix: Resolve Migration Crash (#1967)
init
2026-08-08 15:38:25 +05:30
Rajarshee Chatterjee e0c89cdd9f fix: Neutralize Skeleton Loading Colors 2026-08-05 07:07:20 +05:30
Rajarshee Chatterjee 3bf025108a fix: Restore Indonesian Translations 2026-08-05 06:05:03 +05:30
Rajarshee Chatterjee f69e5d6a7e chore: Update Translations (#1934)
* New translations strings.json (French)

[ci skip]
[ci skip]

* New translations strings.json (Spanish)

[ci skip]
[ci skip]

* New translations strings.json (Afrikaans)

[ci skip]
[ci skip]

* New translations strings.json (Arabic)

[ci skip]
[ci skip]

* New translations strings.json (Catalan)

[ci skip]
[ci skip]

* New translations strings.json (Czech)

[ci skip]
[ci skip]

* New translations strings.json (Danish)

[ci skip]
[ci skip]

* New translations strings.json (German)

[ci skip]
[ci skip]

* New translations strings.json (Greek)

[ci skip]
[ci skip]

* New translations strings.json (Finnish)

[ci skip]
[ci skip]

* New translations strings.json (Hebrew)

[ci skip]
[ci skip]

* New translations strings.json (Hungarian)

[ci skip]
[ci skip]

* New translations strings.json (Italian)

[ci skip]
[ci skip]

* New translations strings.json (Japanese)

[ci skip]
[ci skip]

* New translations strings.json (Korean)

[ci skip]
[ci skip]

* New translations strings.json (Dutch)

[ci skip]
[ci skip]

* New translations strings.json (Norwegian)

[ci skip]
[ci skip]

* New translations strings.json (Polish)

[ci skip]
[ci skip]

* New translations strings.json (Portuguese)

[ci skip]
[ci skip]

* New translations strings.json (Russian)

[ci skip]
[ci skip]

* New translations strings.json (Albanian)

[ci skip]
[ci skip]

* New translations strings.json (Serbian (Cyrillic))

[ci skip]
[ci skip]

* New translations strings.json (Swedish)

[ci skip]
[ci skip]

* New translations strings.json (Turkish)

[ci skip]
[ci skip]

* New translations strings.json (Ukrainian)

[ci skip]
[ci skip]

* New translations strings.json (Chinese Simplified)

[ci skip]
[ci skip]

* New translations strings.json (Chinese Traditional)

[ci skip]
[ci skip]

* New translations strings.json (Vietnamese)

[ci skip]
[ci skip]

* New translations strings.json (Portuguese, Brazilian)

[ci skip]
[ci skip]

* New translations strings.json (Indonesian)

[ci skip]
[ci skip]

* New translations strings.json (Hindi)

[ci skip]
[ci skip]

* New translations strings.json (Assamese)

[ci skip]
[ci skip]

* New translations strings.json (Odia)

[ci skip]
[ci skip]

* New translations strings.json (Romanian)

[ci skip]
[ci skip]

* New translations strings.json (French)

[ci skip]
[ci skip]

* New translations strings.json (Spanish)

[ci skip]
[ci skip]

* New translations strings.json (Afrikaans)

[ci skip]
[ci skip]

* New translations strings.json (Arabic)

[ci skip]
[ci skip]

* New translations strings.json (Catalan)

[ci skip]
[ci skip]

* New translations strings.json (Czech)

[ci skip]
[ci skip]

* New translations strings.json (Danish)

[ci skip]
[ci skip]

* New translations strings.json (German)

[ci skip]
[ci skip]

* New translations strings.json (Greek)

[ci skip]
[ci skip]

* New translations strings.json (Finnish)

[ci skip]
[ci skip]

* New translations strings.json (Hebrew)

[ci skip]
[ci skip]

* New translations strings.json (Hungarian)

[ci skip]
[ci skip]

* New translations strings.json (Italian)

[ci skip]
[ci skip]

* New translations strings.json (Japanese)

[ci skip]
[ci skip]

* New translations strings.json (Korean)

[ci skip]
[ci skip]

* New translations strings.json (Dutch)

[ci skip]
[ci skip]

* New translations strings.json (Norwegian)

[ci skip]
[ci skip]

* New translations strings.json (Polish)

[ci skip]
[ci skip]

* New translations strings.json (Portuguese)

[ci skip]
[ci skip]

* New translations strings.json (Russian)

[ci skip]
[ci skip]

* New translations strings.json (Albanian)

[ci skip]
[ci skip]

* New translations strings.json (Serbian (Cyrillic))

[ci skip]
[ci skip]

* New translations strings.json (Swedish)

[ci skip]
[ci skip]

* New translations strings.json (Turkish)

[ci skip]
[ci skip]

* New translations strings.json (Ukrainian)

[ci skip]
[ci skip]

* New translations strings.json (Chinese Simplified)

[ci skip]
[ci skip]

* New translations strings.json (Chinese Traditional)

[ci skip]
[ci skip]

* New translations strings.json (Vietnamese)

[ci skip]
[ci skip]

* New translations strings.json (Portuguese, Brazilian)

[ci skip]
[ci skip]

* New translations strings.json (Indonesian)

[ci skip]
[ci skip]

* New translations strings.json (Hindi)

[ci skip]
[ci skip]

* New translations strings.json (Assamese)

[ci skip]
[ci skip]

* New translations strings.json (Odia)

[ci skip]
[ci skip]

* New translations strings.json (Romanian)

[ci skip]
[ci skip]
2026-08-05 05:37:25 +05:30
Rajarshee Chatterjee a727c229ca fix: Optimize Novel Counter Migration 2026-08-05 05:32:58 +05:30
Rajarshee Chatterjee 57eca11a9e fix: Pass Library Status Through Novel Navigation 2026-08-04 22:06:05 +05:30
Rajarshee Chatterjee 197d8670f8 fix: Resolve EPUB Cover Document Imports (Closes #1948) 2026-08-04 20:49:38 +05:30
Rajarshee Chatterjee 675f19ef90 fix: Make Update Chapters Reactive and Remove Display Limit (Closes #1953) 2026-08-04 20:25:19 +05:30
Rajarshee Chatterjee 8a12529ba8 fix: Prevent Update Clearing From Freezing App (Closes #1955) 2026-08-04 19:52:11 +05:30
Rajarshee Chatterjee c3482a8510 fix: Resolve Nitro EPUB Jest Alias 2026-08-04 19:41:27 +05:30
Rajarshee Chatterjee e4246dee59 fix: Replace EPUB Exports Atomically With NativeFile (Closes #1954) 2026-08-04 19:32:12 +05:30
Rajarshee Chatterjee 23f9b183be fix: Refresh Updates Screen on Focus (Closes #1956) 2026-08-04 19:13:05 +05:30
Rajarshee Chatterjee 1eb8c587c7 fix: Prevent Library From Remaining in Loading State 2026-08-04 18:45:16 +05:30
Rajarshee Chatterjee c3b75ebeb2 fix: Migrate Plugin Bundles to Reliable Internal Storage
Closes #1907
2026-08-04 16:34:19 +05:30
Rajarshee Chatterjee 91358ad3dd fix: Support All EPUB Image Formats (Closes #1946) 2026-08-04 10:59:53 +05:30
Rajarshee Chatterjee 13885320ae fix: Add Novels To Library When Setting Categories (Closes #1945) 2026-08-03 22:59:14 +05:30
Rajarshee Chatterjee b9d1abcf27 fix: Refresh Library Download Counts After Deletion 2026-08-03 20:56:05 +05:30
Rajarshee Chatterjee c3260e8e01 fix: Resolve Chapter Download Queue and Removal Issues (Closes #1937) 2026-08-01 17:10:29 +05:30
Rajarshee Chatterjee c0877a9b06 feat: Add Ignorable App Update Notifications
Closes #600
Closes #1632
2026-08-01 12:06:28 +05:30
Patrick Loser 96fb52df17 feat: Enhance Stats With Genre Taxonomy and Charts (#1919)
* Add react-native-svg and refresh stats UI

Normalize status counts, add donut chart with legend, and improve bars

* Add genre stats screen and taxonomy link

* Update StatsQueries.test.ts

* change genre taxonomy style

* Use LegendList instead of ScrollView

* added more colors to chart

* Add Highlight animation

* Add ChartLegend animation

* Maintainance

* updated stats queries

* Added animations to collapsibles and optimized performance

* Extract tabs

* update time tracking tab
2026-08-01 11:31:03 +05:30
Rajarshee Chatterjee 76106317b6 fix: Restore Midnight Dusk Chip Contrast (Closes #1939) 2026-07-31 08:11:08 +05:30
K1ngfish3r e75f4fba5d feat: Add More Novel Statuses and Icons (#1931)
add more novelStatus and icons
2026-07-29 22:30:20 +05:30
Rajarshee Chatterjee 4dc933ee5a fix: Remove White Flash During Screen Transitions (Closes #1926) (#1927)
`AppTheme` never set `android:windowBackground`, so it was inherited from
`Theme.AppCompat.DayNight.NoActionBar` and resolved to a near-white colour
whenever the system was in light mode — independently of the theme picked
inside the app. Nothing between that window and the navigators painted a
background of its own: the root view, `GestureHandlerRootView` and
`SafeAreaProvider` are all transparent, and only the individual screens carry
`contentStyle`. Any frame in which no screen was drawn — mounting a nested
navigator such as `MoreStack` or `ReaderStack`, or freezing the screen being
left — therefore showed the white window through.

Paint the root view with the active theme background so the window stays
covered, and give `AppTheme` a day/night `android:windowBackground` so the
frames before React draws are themed as well. The chapter screen additionally
uses the reader background instead of the app background, so pushing it no
longer flashes the app theme before the reader appears.


Claude-Session: https://claude.ai/code/session_014uxuC7JVkShhWnN1WhkTaH

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-29 21:27:22 +05:30
Rajarshee Chatterjee bfb12b04aa feat: Add Configurable Automatic Backup Scheduling 2026-07-29 21:20:19 +05:30
Patrick Loser b7639ab55a feat: Upload JavaScript Source Maps (#1924)
* init

* fix paths

* add hermes flag
2026-07-29 20:58:00 +05:30
360 changed files with 27616 additions and 4841 deletions
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+3 -3
View File
@@ -2,7 +2,7 @@
name: Issue report
description: Report an issue in LNReader
labels: [Bug]
labels: [bug]
body:
- type: textarea
id: reproduce-steps
@@ -45,7 +45,7 @@ body:
label: LNReader version
description: You can find your LNReader version in **More → About**.
placeholder: |
Example: "2.1.2"
Example: "2.1.4"
validations:
required: true
@@ -88,7 +88,7 @@ body:
required: true
- label: If this is an issue with a plugin, I should be opening an issue in the [plugins repository](https://github.com/lnreader/lnreader-plugins/issues/new/choose).
required: true
- label: I have updated the app to version **[2.1.2](https://github.com/lnreader/lnreader/releases/latest)**.
- label: I have updated the app to version **[2.1.4](https://github.com/lnreader/lnreader/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true
+2 -2
View File
@@ -2,7 +2,7 @@
name: Feature request
description: Suggest a feature to improve LNReader
labels: [Feature Request]
labels: [feature]
body:
- type: textarea
id: feature-description
@@ -34,7 +34,7 @@ body:
required: true
- label: If this is an issue with a source, I should be opening an issue in the [sources repository](https://github.com/lnreader/lnreader-sources/issues/new/choose).
required: true
- label: I have updated the app to version **[2.1.2](https://github.com/lnreader/lnreader/releases/latest)**.
- label: I have updated the app to version **[2.1.4](https://github.com/lnreader/lnreader/releases/latest)**.
required: true
- label: I will fill out all of the requested information in this form.
required: true
+8
View File
@@ -146,6 +146,14 @@ jobs:
app/build/outputs/apk/preRelease/app-preRelease.apk \
"app/build/outputs/apk/preRelease/$PRERELEASE_APK_NAME"
- name: Upload Preview Sourcemap
uses: actions/upload-artifact@v4
with:
name: ${{ env.PRERELEASE_ARTIFACT_NAME }}-sourcemap
path: android/app/build/generated/sourcemaps/react/preRelease/index.android.bundle.map
if-no-files-found: error
retention-days: 14
compression-level: 0
- name: Upload Preview APK
id: upload-nightly
uses: actions/upload-artifact@v4
+5
View File
@@ -183,6 +183,11 @@ jobs:
- name: Package Release APKs
run: pnpm release:package "$RELEASE_VERSION"
- name: Upload Sourcemaps
run: |
cp android/app/build/generated/sourcemaps/react/release/index.android.bundle.map \
"release-artifacts/LNReader-v${RELEASE_VERSION}-sourcemap.map"
- name: Verify APK Signatures
run: |
APKSIGNER="$(find "$ANDROID_HOME/build-tools" -type f -name apksigner |
+3
View File
@@ -1 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
pnpm run type-check
+132
View File
@@ -0,0 +1,132 @@
# AGENTS.md
Guidance for AI coding agents working in this repository.
## Project overview
LNReader is a free, open-source light novel reader for Android, built with
React Native (Expo) and TypeScript. Content is fetched at runtime by
JavaScript "plugins" (per-source scrapers) rather than bundled with the app.
Plugin _requests_ live in a separate repo, [lnreader-plugins](https://github.com/lnreader/lnreader-plugins);
this repo is the reader app itself.
## Setup
```bash
pnpm install
```
- Node >= 22.11.0, pnpm (see `packageManager` in `package.json`, currently pnpm@11.20.0).
- This is a React Native/Expo app targeting Android; there is no web or iOS
build target maintained here. Building/running the app requires an Android
SDK and a device/emulator, which is generally not available in an agent
sandbox — prefer running lint, type-check, and tests to verify changes.
- See `CONTRIBUTING.md` (and `CONTRIBUTING-NIX.md` for a Nix-based dev shell)
for full local Android setup instructions.
## Commands
Run these from the repo root:
```bash
pnpm run lint # eslint ./src
pnpm run lint:fix # eslint --fix
pnpm run format:check # prettier --check
pnpm run format # prettier --write
pnpm run type-check # tsc --noEmit
pnpm run check # format:check + lint + type-check (run before finishing any change)
pnpm test # jest (all projects)
pnpm run test:rn # jest --selectProjects rn (React Native/component/hook tests)
pnpm run test:db # jest --selectProjects db (database/query tests, node env)
pnpm run test:watch
pnpm run test:coverage
```
Always run `pnpm run check` (or at minimum lint + type-check + the relevant
test project) before considering a change complete. A pre-commit hook
(`husky` + `lint-staged`) runs prettier/eslint on staged files, and a
pre-push hook runs `type-check`, so failures here will also block commits/pushes.
## Code style
- TypeScript, functional React components, hooks-based state.
- Formatting is enforced by Prettier (`.prettierrc.js`) — don't hand-format,
run `pnpm run format` instead.
- ESLint config is `eslint.config.js` (flat config, based on `eslint-config-expo`).
Notable rules: `no-console` is an error (use the project's logging/toast
utilities, not `console.*`), `prefer-const`, `no-var`, and several
`react-hooks/*` correctness rules are enforced.
- Use the `@alias/*` path aliases defined in `tsconfig.json` (and mirrored in
`jest.config.js`) instead of deep relative imports, e.g. `@components`,
`@hooks`, `@database/*`, `@screens/*`, `@services/*`, `@utils/*`,
`@api/*`, `@theme/*`, `@plugins/*`, `@navigators/*`, `@i18n/*`, `@type/*`.
- Don't add comments that just restate what the code does; only comment
non-obvious "why" (workarounds, invariants, native/platform quirks).
## Localization (i18n)
- `src/i18n/languages/en/strings.json` is the source-of-truth string file —
it's the only locale you should hand-edit. Every other folder under
`src/i18n/languages/*` is a translation synced automatically from Crowdin
(see `crowdin.yml`); don't edit or "fix" translated strings in those files.
- After adding/changing keys in `en/strings.json`, run
`pnpm run generate:string-types` to regenerate the associated TypeScript types.
## Repository layout
- `src/api` – external API clients (drive/cloud backup providers, etc.)
- `src/components` – shared/reusable UI components
- `src/database` – SQLite (op-sqlite/Drizzle) schema, queries, and migrations
helpers; `src/database/queries/*Queries.ts` are the main data-access layer
- `src/hooks` – shared and "persisted" (MMKV-backed) React hooks
- `src/i18n` – translated strings (see Crowdin integration in `crowdin.yml`)
- `src/navigators` – React Navigation stack/tab setup
- `src/plugins` – plugin manager and plugin type definitions (not the plugin
source scrapers themselves, which live in the separate lnreader-plugins repo)
- `src/screens` – feature screens (library, browse, novel, reader, settings, etc.)
- `src/services` – background tasks, downloads, backups, trackers, EPUB export, updates
- `src/theme` – Material3 theming
- `src/utils` – general utilities
- `drizzle/` – generated SQL migrations (see `pnpm run generate:db-migration`)
- `modules/` – native modules (Nitro modules: epub, tts, file, zip-archive, etc.)
- `test/` – Jest global mocks and the shared `test-utils.tsx` render helpers
- `scripts/` – Node scripts for env file generation, release packaging, etc.
## Testing notes
- Don't add tests for the sake of coverage. Write tests for user-facing
interactions (screens, components a user directly touches) and for core
complex logic (parsing, database queries, sync/backup, chapter/novel
matching, etc.), not for trivial passthroughs, simple type/prop plumbing,
or generated/boilerplate code.
- Tests live in `__tests__` directories next to the code under test and use
Jest + `@testing-library/react-native`.
- Jest is split into two projects (see `jest.config.js`):
- `db` — pure logic/query tests under `src/database`, run in a Node environment.
- `rn` — component/hook/integration tests under `src/`, run with the `jest-expo` preset.
- Import the shared render helpers from `@test-utils` (`render`, `renderNovel`,
`AllTheProviders`) rather than using `@testing-library/react-native`'s
`render` directly, so providers (theme, gesture handler, safe area, novel
context) are set up consistently.
- Mock at module level with `jest.mock(...)`, not inside `beforeEach` — see
`TESTING.md` for the reasoning and common pitfalls (ESM transform errors,
`mockReturnValue` failures, test isolation).
- Global mocks for native modules and database queries live in `test/mocks/`;
hook-specific mocks live in `src/hooks/__mocks__` and `src/hooks/__tests__/mocks.ts`.
## Database changes
- Schema lives under `src/database/schema`; the ORM is Drizzle
(`drizzle-orm`, config in `drizzle.config.ts`).
- After changing the schema, generate a migration rather than hand-writing
SQL: `pnpm run generate:db-migration`. Do not edit files under `drizzle/`
by hand.
## PR / commit conventions
- Keep changes scoped and consistent with existing patterns in the touched
directory; prefer editing existing files over introducing new abstractions.
- Match existing code style; don't reformat unrelated code.
- If you are an AI agent opening a PR or making a commit, attribute it as
such (e.g. a "Generated with <agent/tool name>" note in the commit
message/PR description, and a co-author trailer if your tool supports one).
+48 -20
View File
@@ -14,6 +14,7 @@ import {
import AppErrorBoundary, {
ErrorFallback,
} from '@components/AppErrorBoundary/AppErrorBoundary';
import CrashRecoveryGate from '@components/AppErrorBoundary/CrashRecoveryGate';
import Main from './src/navigators/Main';
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
@@ -22,10 +23,31 @@ import { useInitializeAppServices } from '@hooks/common/useInitializeAppServices
import { opSqliteAdapter } from './src/rozenite/opSqliteAdapter';
import { useRozeniteSqlitePlugin } from '@rozenite/sqlite-plugin';
import { ThemeProvider, useTheme } from '@hooks/persisted/useTheme';
import { KeyboardProvider } from 'react-native-keyboard-controller';
enableFreeze(true);
const sqliteAdapters = __DEV__ && opSqliteAdapter ? [opSqliteAdapter] : [];
/**
* The Android window background is resolved from the system light/dark setting,
* so it is bright white whenever the system is light – regardless of the theme
* picked inside the app. Nothing between it and the navigators paints a
* background of its own, so every frame in which no screen is drawn (mounting a
* nested navigator, freezing the screen being left) shows through as a flash.
* Painting the root view keeps the window covered at all times.
*/
const ThemedRootView = ({ children }: PropsWithChildren) => {
const theme = useTheme();
return (
<GestureHandlerRootView
style={[styles.flex, { backgroundColor: theme.background }]}
>
{children}
</GestureHandlerRootView>
);
};
const ThemedPaperProvider = ({ children }: PropsWithChildren) => {
const theme = useTheme();
const paperTheme = useMemo(() => {
@@ -43,7 +65,7 @@ const ThemedPaperProvider = ({ children }: PropsWithChildren) => {
return <PaperProvider theme={paperTheme}>{children}</PaperProvider>;
};
const App = () => {
const Application = () => {
useRozeniteSqlitePlugin({ adapters: sqliteAdapters });
const { success: databaseReady, error: databaseError } = useInitDatabase();
const { ready: servicesReady, error: servicesError } =
@@ -59,9 +81,7 @@ const App = () => {
if (initializationError) {
return (
<ThemeProvider>
<ErrorFallback error={initializationError} resetError={() => null} />
</ThemeProvider>
<ErrorFallback error={initializationError} resetError={() => null} />
);
}
@@ -70,25 +90,33 @@ const App = () => {
}
return (
<Suspense fallback={null}>
<GestureHandlerRootView style={styles.flex}>
<ThemeProvider>
<AppErrorBoundary>
<SafeAreaProvider>
<ThemedPaperProvider>
<BottomSheetModalProvider>
<StatusBar translucent={true} backgroundColor="transparent" />
<Main />
</BottomSheetModalProvider>
</ThemedPaperProvider>
</SafeAreaProvider>
</AppErrorBoundary>
</ThemeProvider>
</GestureHandlerRootView>
</Suspense>
<ThemedRootView>
<KeyboardProvider>
<AppErrorBoundary>
<SafeAreaProvider>
<ThemedPaperProvider>
<BottomSheetModalProvider>
<StatusBar translucent={true} backgroundColor="transparent" />
<Main />
</BottomSheetModalProvider>
</ThemedPaperProvider>
</SafeAreaProvider>
</AppErrorBoundary>
</KeyboardProvider>
</ThemedRootView>
);
};
const App = () => (
<Suspense fallback={null}>
<ThemeProvider>
<CrashRecoveryGate>
<Application />
</CrashRecoveryGate>
</ThemeProvider>
</Suspense>
);
export default App;
const styles = StyleSheet.create({
+73
View File
@@ -0,0 +1,73 @@
# Debugging Production Builds
Symbolicate production stack traces from LNReader using uploaded sourcemaps.
## Prerequisites
- **Node.js** (v18 or later)
- The **sourcemap** matching the crashing APK version
- A **stack trace** from either `adb logcat` (device attached) or pasted from a Discord / GitHub issue report
## Getting the Sourcemap
### Release builds
Sourcemaps are uploaded as GitHub release assets. Download the one matching the crashing version:
```
LNReader-v{version}-sourcemap.map
```
Example for `v2.5.0`:
1. Go to the [releases page](https://github.com/lnreader/lnreader/releases)
2. Find release `v2.5.0`
3. Under **Assets**, download `LNReader-v2.5.0-sourcemap.map`
### Preview / nightly builds
Sourcemaps are uploaded as a separate artifact alongside the preview APK, named `LNReader-{sha}-sourcemap`.
1. Go to the [Actions tab](https://github.com/lnreader/lnreader/actions) → **Build Preview**
2. Find the workflow run matching the build hash
3. Under **Artifacts**, download the one with the `-sourcemap` suffix
## Symbolicating a Stack Trace
Save the raw stack trace to a file (from `adb logcat`, or paste from Discord/GitHub into `trace.txt`), then run:
```bash
npx metro-symbolicate <sourcemap.map> < trace.txt
```
Or pipe it directly:
```bash
adb logcat -d | npx metro-symbolicate <sourcemap.map>
```
## Example
Before (Hermes-obfuscated):
```
com.lnreader E Error: Unexpected token
at 93795 (address at index.js:1:729193)
at 28471 (address at index.js:1:219482)
at 10384 (address at index.js:1:81729)
```
After symbolication:
```
com.lnreader E Error: Unexpected token
at handleResponse (src/api/plugins.ts:120:12)
at fetchPlugin (src/hooks/usePlugins.ts:45:8)
at getPluginList (src/screens/browse/BrowseScreen.tsx:89:20)
```
## Troubleshooting
- **Version mismatch**: the sourcemap must match the exact APK build. A mismatched version produces wrong file names and line numbers.
- **No sourcemap artifact**: verify the workflow that built the APK ran successfully and that the "Upload Sourcemaps" / "Upload Preview Sourcemap" step completed without errors.
- **Hermes bytecode**: Hermes compiles JS to bytecode. The sourcemap is generated during this step and maps bytecode offsets back to source. `metro-symbolicate` handles this transparently.
+20 -4
View File
@@ -1,6 +1,6 @@
{
"expo": {
"version": "2.1.2",
"version": "2.1.4",
"name": "LNReader",
"slug": "LNReader",
"scheme": "lnreader",
@@ -30,7 +30,14 @@
}
],
"expo-localization",
"react-native-edge-to-edge",
[
"react-native-edge-to-edge",
{
"android": {
"enforceNavigationBarContrast": false
}
}
],
"expo-web-browser",
"./plugins/withReaderAssets",
[
@@ -41,13 +48,15 @@
"manifestApplicationAttributes": {
"android:largeHeap": "true",
"android:allowBackup": "true"
}
},
"enableMinifyInReleaseBuilds": true,
"enableShrinkResourcesInReleaseBuilds": true
}
}
]
],
"android": {
"versionCode": 4196354,
"versionCode": 4196356,
"package": "com.rajarsheechatterjee.LNReader",
"adaptiveIcon": {
"foregroundImage": "./assets/native/ic_launcher_foreground.png",
@@ -61,6 +70,13 @@
"android.permission.FOREGROUND_SERVICE_DATA_SYNC",
"android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK",
"android.permission.POST_NOTIFICATIONS"
],
"intentFilters": [
{
"action": "SEND",
"category": ["DEFAULT"],
"data": [{ "mimeType": "text/plain" }]
}
]
},
"extra": {
+35
View File
@@ -14,6 +14,41 @@ html {
background-color: var(--theme-secondary);
}
#text-selection-ui {
position: fixed;
z-index: 100000;
display: flex;
overflow: hidden;
border: 1px solid var(--theme-outlineVariant);
border-radius: 24px;
background-color: var(--theme-surface-0-9);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
opacity: 0;
pointer-events: none;
transition: opacity 150ms;
user-select: none;
}
#text-selection-ui button {
min-width: 88px;
min-height: 44px;
padding: 0 16px;
border: 0;
background: transparent;
color: var(--theme-onSurface);
font-size: 14px;
font-weight: 600;
touch-action: manipulation;
}
#text-selection-ui button + button {
border-inline-start: 1px solid var(--theme-outlineVariant);
}
#text-selection-ui button:active {
background-color: var(--theme-surfaceVariant);
}
body {
margin-left: 0;
margin-right: 0;
+6
View File
@@ -15,6 +15,12 @@ body.page-reader > #LNReader-chapter {
column-fill: auto;
transition: transform 200ms;
}
body.page-reader.no-animation > #LNReader-chapter {
transition: none !important;
}
body.page-reader.no-animation .transition-chapter {
transition: none !important;
}
.transition-chapter {
height: 100vh;
width: 100vw;
+67 -14
View File
@@ -211,10 +211,22 @@ window.tts = new (function () {
this.normalizeText = text => {
if (!text) return '';
return text
const normalized = text
.replace(/\s+/g, ' ')
.trim()
.replace(/^["'“”‘’]+|["'“”‘’]+$/g, '')
.replace(/\s*([.,!?;:])\s*/g, '$1 ')
.trim();
const dashOnlyText = normalized.replace(/\s/g, '');
if (
dashOnlyText.length >= 3 &&
/^[\-‐‑‒–—―−⁓⸺⸻﹘﹣-]+$/u.test(dashOnlyText)
) {
return '';
}
return normalized;
};
// if can find a readable node, else stop tts
@@ -332,6 +344,9 @@ window.tts = new (function () {
if (!el) return;
if (this.readable(el)) {
elements.push(el);
// innerText already includes readable descendants, so descending any
// further would add overlapping text to the speech queue.
return;
}
for (let i = 0; i < el.children.length; i++) {
traverse(el.children[i]);
@@ -509,7 +524,8 @@ van.derive(() => {
}
});
window.pageReader = new (function () {
var pageReader;
window.pageReader = pageReader = new (function () {
const config =
typeof initialPageReaderConfig === 'undefined'
? {}
@@ -529,18 +545,21 @@ window.pageReader = new (function () {
document.getElementsByClassName('transition-chapter')[0];
if (!this.chapterEnding) return;
}
const noAnim = Boolean(
reader.generalSettings?.val?.pageReaderDisableAnimation,
);
this.chapterEnding.style.transition = 'unset';
if (bool) {
this.chapterEnding.style.transform = `translateX(${left ? -200 : 0}vw)`;
requestAnimationFrame(() => {
if (!instant) {
if (!instant && !noAnim) {
this.chapterEnding.style.transition = 'transform 200ms';
}
this.chapterEnding.style.transform = 'translateX(-100vw)';
});
this.chapterEndingVisible.val = true;
} else {
if (!instant) {
if (!instant && !noAnim) {
this.chapterEnding.style.transition = 'transform 200ms';
}
this.chapterEnding.style.transform = `translateX(${left ? -200 : 0}vw)`;
@@ -564,26 +583,35 @@ window.pageReader = new (function () {
return;
}
destPage = parseInt(destPage, 10);
const noAnim = Boolean(
reader.generalSettings?.val?.pageReaderDisableAnimation,
);
if (destPage < 0) {
if (!reader.prevChapter) return;
document.getElementsByClassName('transition-chapter')[0].innerText =
reader.prevChapter.name;
this.showChapterEnding(true, false, true);
this.showChapterEnding(true, noAnim, true);
this.chapterNavigationPending = true;
setTimeout(() => {
reader.post({ type: 'prev' });
}, 200);
setTimeout(
() => {
reader.post({ type: 'prev' });
},
noAnim ? 0 : 200,
);
return;
}
if (destPage >= this.totalPages.val) {
if (!reader.nextChapter) return;
document.getElementsByClassName('transition-chapter')[0].innerText =
reader.nextChapter.name;
this.showChapterEnding(true);
this.showChapterEnding(true, noAnim);
this.chapterNavigationPending = true;
setTimeout(() => {
reader.post({ type: 'next' });
}, 200);
setTimeout(
() => {
reader.post({ type: 'next' });
},
noAnim ? 0 : 200,
);
return;
}
this.page.val = destPage;
@@ -591,7 +619,7 @@ window.pageReader = new (function () {
'translateX(-' + destPage * 100 + '%)';
const newProgress = parseInt(
((pageReader.page.val + 1) / pageReader.totalPages.val) * 100,
((this.page.val + 1) / this.totalPages.val) * 100,
10,
);
@@ -672,6 +700,18 @@ window.pageReader = new (function () {
});
}
});
van.derive(() => {
document.body.classList.toggle(
'no-animation',
Boolean(reader.generalSettings.val.pageReaderDisableAnimation),
);
reader.chapterElement?.style.removeProperty('transition');
const chapterEnding =
this.chapterEnding ||
document.getElementsByClassName('transition-chapter')[0];
chapterEnding?.style.removeProperty('transition');
});
})();
document.addEventListener('DOMContentLoaded', () => {
@@ -890,7 +930,11 @@ window.addEventListener('load', () => {
if (Math.abs(diffX) > 8 || Math.abs(diffY) > 8) {
pageReader.ignoreClickUntil = Date.now() + 400;
}
reader.chapterElement.style.transition = 'transform 200ms';
reader.chapterElement.style.transition = reader.generalSettings.val
.pageReaderDisableAnimation
? 'none'
: 'transform 200ms';
reader.chapterElement.style.removeProperty('transition');
const diffXPercentage = diffX / reader.layoutWidth;
if (diffXPercentage < -0.3) {
pageReader.movePage(pageReader.page.val + 1);
@@ -907,6 +951,15 @@ window.addEventListener('load', () => {
) {
return;
}
if (
diffY > 80 &&
Math.abs(diffY) > Math.abs(diffX) * 2 &&
window.scrollY <= 0
) {
e.preventDefault();
reader.post({ type: 'refresh' });
return;
}
if (
reader.generalSettings.val.swipeGestures &&
Math.abs(diffX) > Math.abs(diffY) * 2 &&
+2
View File
@@ -29,6 +29,8 @@ export interface Reader {
finished: string;
nextChapter: string;
noNextChapter: string;
removeText: string;
replaceText: string;
};
setAdjacentChapters: (adjacent: {
+239
View File
@@ -0,0 +1,239 @@
// Text selection functionality
window.textRemover = new (function () {
let selectionUI = null;
let isUIActive = false;
function createSelectionUI() {
if (selectionUI) return selectionUI;
const { div, button } = van.tags;
selectionUI = div(
{
id: 'text-selection-ui',
role: 'group',
},
button(
{
type: 'button',
onclick: e => {
if (reader.hidden.val) {
e.stopPropagation();
}
removeSelectedText();
},
},
reader.strings.removeText,
),
button(
{
type: 'button',
onclick: e => {
if (reader.hidden.val) {
e.stopPropagation();
}
replaceSelectedText();
},
},
reader.strings.replaceText,
),
);
document.body.appendChild(selectionUI);
return selectionUI;
}
function showSelectionUI() {
const ui = createSelectionUI();
// Get selection bounds
const selection = window.getSelection();
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
// Get UI element heights from CSS variables (with fallbacks)
const statusBarHeight =
parseInt(
getComputedStyle(document.documentElement).getPropertyValue(
'--StatusBar-currentHeight',
),
10,
) || 24;
const navigationBarHeight =
parseInt(
getComputedStyle(document.documentElement).getPropertyValue(
'--bottom-inset',
),
10,
) || 24;
const readerPadding =
parseInt(
getComputedStyle(document.documentElement).getPropertyValue(
'--readerSettings-padding',
),
10,
) || 16;
const uiHeight = 50; // Approximate height of our UI
// Calculate available space
const viewportHeight = window.innerHeight;
const selectionCenterY = rect.top + rect.height / 2;
const topSafeArea = statusBarHeight + readerPadding + 10;
const bottomSafeArea = readerPadding + uiHeight + navigationBarHeight;
// Position UI based on selection location
let topPosition;
if (selectionCenterY < (viewportHeight / 5) * 4) {
// Selection is in top 4/5, position UI at bottom
//TODO: make this dynamic
const avoidScrollbar = reader.generalSettings.val.verticalSeekbar
? 0
: 42;
const avoidUI = !reader.hidden.val ? 46 + avoidScrollbar : 0;
topPosition = viewportHeight - bottomSafeArea - avoidUI - 4;
ui.style.top = topPosition + 'px';
ui.style.bottom = 'auto';
} else {
// Selection is in bottom 1/5, position UI at top (accounting for status bar)
topPosition = Math.max(topSafeArea, statusBarHeight + 20);
const avoidUI = !reader.hidden.val ? 34 : 0;
ui.style.top = topPosition + avoidUI + 'px';
ui.style.bottom = 'auto';
}
// Center horizontally
ui.style.left = '50%';
ui.style.transform = 'translateX(-50%)';
} else {
// Fallback: position at top if no selection rect available
ui.style.top = '20px';
ui.style.left = '50%';
ui.style.transform = 'translateX(-50%)';
ui.style.bottom = 'auto';
}
ui.style.opacity = '1';
ui.style.pointerEvents = 'auto';
isUIActive = true;
}
function hideSelectionUI() {
if (selectionUI) {
selectionUI.style.opacity = '0';
selectionUI.style.pointerEvents = 'none';
}
isUIActive = false;
}
function getSelectedText() {
const selection = window.getSelection();
if (selection.rangeCount > 0) {
return selection.toString().trim();
}
return '';
}
function removeSelectedText() {
const selectedText = getSelectedText();
if (selectedText) {
reader.post({
type: 'text-action',
data: { remove: selectedText },
});
}
hideSelectionUI();
window.getSelection().removeAllRanges();
}
function replaceSelectedText() {
const selectedText = getSelectedText();
if (selectedText) {
// For replace, we need user input, so send a different message
reader.post({
type: 'text-action',
data: { replace: selectedText },
});
}
hideSelectionUI();
window.getSelection().removeAllRanges();
}
// Handle text selection
document.addEventListener('selectionchange', function () {
const selectedText = getSelectedText();
if (selectedText) {
showSelectionUI();
} else if (!isUIActive) {
hideSelectionUI();
}
});
// Hide UI when clicking/tapping elsewhere
document.addEventListener('touchstart', function (e) {
if (isUIActive && selectionUI && !selectionUI.contains(e.target)) {
const selectedText = getSelectedText();
if (!selectedText) {
hideSelectionUI();
}
}
});
document.addEventListener('click', function (e) {
if (isUIActive && selectionUI && !selectionUI.contains(e.target)) {
const selectedText = getSelectedText();
if (!selectedText) {
hideSelectionUI();
}
}
});
// Hide UI on scroll
window.addEventListener('scroll', function () {
hideSelectionUI();
});
})();
/**
* Directly remove text from the chapter DOM without reloading the WebView.
* Also updates reader.rawHTML so the text-options deriver (bionic reading,
* paragraph spacing) doesn't re-introduce removed text on its next run.
*/
window.textRemover.performRemove = function (text) {
const el = document.querySelector('#LNReader-chapter');
if (!el) return;
const m = text.match(/^\/(.*)\/([gmiyuvsd]*)$/);
let result;
if (m) {
try {
result = el.innerHTML.replace(new RegExp(m[1], m[2]), '');
} catch (_e) {
return;
}
} else {
result = el.innerHTML.split(text).join('');
}
el.innerHTML = result;
reader.rawHTML = result;
};
/**
* Directly replace text in the chapter DOM without reloading the WebView.
* Also updates reader.rawHTML so the text-options deriver stays in sync.
*/
window.textRemover.performReplace = function (from, to) {
const el = document.querySelector('#LNReader-chapter');
if (!el) return;
const m = from.match(/^\/(.*)\/([gmiyuvsd]*)$/);
let result;
if (m) {
try {
result = el.innerHTML.replace(new RegExp(m[1], m[2]), to);
} catch (_e) {
return;
}
} else {
result = el.innerHTML.split(from).join(to);
}
el.innerHTML = result;
reader.rawHTML = result;
};
+1
View File
@@ -28,6 +28,7 @@ export default function (api) {
'@specs': './specs',
'@test-utils': './test/test-utils',
'@env': './src/generated/build-info',
'@modules/nitro-epub': './modules/nitro-epub/src/index',
'@modules/nitro-tts': './modules/nitro-tts/src/index',
'@modules': './modules',
'react-native-vector-icons/MaterialCommunityIcons':
@@ -60,34 +60,24 @@ SELECT
`__migration_Novel`.`inLibrary`,
`__migration_Novel`.`isLocal`,
`__migration_Novel`.`totalPages`,
(
SELECT COUNT(*)
FROM `__migration_Chapter`
WHERE `__migration_Chapter`.`novelId` = `__migration_Novel`.`id`
AND `__migration_Chapter`.`isDownloaded` = 1
),
(
SELECT COUNT(*)
FROM `__migration_Chapter`
WHERE `__migration_Chapter`.`novelId` = `__migration_Novel`.`id`
AND `__migration_Chapter`.`unread` = 1
),
(
SELECT COUNT(*)
FROM `__migration_Chapter`
WHERE `__migration_Chapter`.`novelId` = `__migration_Novel`.`id`
),
(
SELECT MAX(`__migration_Chapter`.`readTime`)
FROM `__migration_Chapter`
WHERE `__migration_Chapter`.`novelId` = `__migration_Novel`.`id`
),
(
SELECT MAX(`__migration_Chapter`.`updatedTime`)
FROM `__migration_Chapter`
WHERE `__migration_Chapter`.`novelId` = `__migration_Novel`.`id`
)
FROM `__migration_Novel`;--> statement-breakpoint
COALESCE(`chapter_stats`.`chaptersDownloaded`, 0),
COALESCE(`chapter_stats`.`chaptersUnread`, 0),
COALESCE(`chapter_stats`.`totalChapters`, 0),
`chapter_stats`.`lastReadAt`,
`chapter_stats`.`lastUpdatedAt`
FROM `__migration_Novel`
LEFT JOIN (
SELECT
`novelId`,
SUM(CASE WHEN `isDownloaded` = 1 THEN 1 ELSE 0 END) AS `chaptersDownloaded`,
SUM(CASE WHEN `unread` = 1 THEN 1 ELSE 0 END) AS `chaptersUnread`,
COUNT(*) AS `totalChapters`,
MAX(`readTime`) AS `lastReadAt`,
MAX(`updatedTime`) AS `lastUpdatedAt`
FROM `__migration_Chapter`
GROUP BY `novelId`
) AS `chapter_stats`
ON `chapter_stats`.`novelId` = `__migration_Novel`.`id`;--> statement-breakpoint
DELETE FROM `NovelCategory`;--> statement-breakpoint
DELETE FROM `Chapter`;--> statement-breakpoint
DROP TABLE IF EXISTS `Novel`;--> statement-breakpoint
@@ -0,0 +1 @@
ALTER TABLE `Repository` ADD `enabled` integer DEFAULT true NOT NULL;
@@ -0,0 +1,654 @@
{
"version": "7",
"dialect": "sqlite",
"id": "2fada83f-d142-46d9-ba03-5ae1e232e3ce",
"prevIds": [
"498c0b85-a3f4-413f-8fed-7914c3ba15d9"
],
"ddl": [
{
"name": "Category",
"entityType": "tables"
},
{
"name": "Chapter",
"entityType": "tables"
},
{
"name": "NovelCategory",
"entityType": "tables"
},
{
"name": "Novel",
"entityType": "tables"
},
{
"name": "Repository",
"entityType": "tables"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "Category"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "Category"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "sort",
"entityType": "columns",
"table": "Category"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "novelId",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "path",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "releaseTime",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "false",
"generated": null,
"name": "bookmark",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "true",
"generated": null,
"name": "unread",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "readTime",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "false",
"generated": null,
"name": "isDownloaded",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updatedTime",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "real",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "chapterNumber",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": "'1'",
"generated": null,
"name": "page",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "position",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "progress",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "scanlator",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "timeSpent",
"entityType": "columns",
"table": "Chapter"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "NovelCategory"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "novelId",
"entityType": "columns",
"table": "NovelCategory"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "categoryId",
"entityType": "columns",
"table": "NovelCategory"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "Novel"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "path",
"entityType": "columns",
"table": "Novel"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "pluginId",
"entityType": "columns",
"table": "Novel"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "Novel"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "cover",
"entityType": "columns",
"table": "Novel"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "summary",
"entityType": "columns",
"table": "Novel"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "author",
"entityType": "columns",
"table": "Novel"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "artist",
"entityType": "columns",
"table": "Novel"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": "'Unknown'",
"generated": null,
"name": "status",
"entityType": "columns",
"table": "Novel"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "genres",
"entityType": "columns",
"table": "Novel"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "false",
"generated": null,
"name": "inLibrary",
"entityType": "columns",
"table": "Novel"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "false",
"generated": null,
"name": "isLocal",
"entityType": "columns",
"table": "Novel"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "totalPages",
"entityType": "columns",
"table": "Novel"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "chaptersDownloaded",
"entityType": "columns",
"table": "Novel"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "chaptersUnread",
"entityType": "columns",
"table": "Novel"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "totalChapters",
"entityType": "columns",
"table": "Novel"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "lastReadAt",
"entityType": "columns",
"table": "Novel"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "lastUpdatedAt",
"entityType": "columns",
"table": "Novel"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "Repository"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "url",
"entityType": "columns",
"table": "Repository"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "true",
"generated": null,
"name": "enabled",
"entityType": "columns",
"table": "Repository"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "Category_pk",
"table": "Category",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "Chapter_pk",
"table": "Chapter",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "NovelCategory_pk",
"table": "NovelCategory",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "Novel_pk",
"table": "Novel",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "Repository_pk",
"table": "Repository",
"entityType": "pks"
},
{
"columns": [
{
"value": "name",
"isExpression": false
}
],
"isUnique": true,
"where": null,
"origin": "manual",
"name": "category_name_unique",
"entityType": "indexes",
"table": "Category"
},
{
"columns": [
{
"value": "sort",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "category_sort_idx",
"entityType": "indexes",
"table": "Category"
},
{
"columns": [
{
"value": "novelId",
"isExpression": false
},
{
"value": "path",
"isExpression": false
}
],
"isUnique": true,
"where": null,
"origin": "manual",
"name": "chapter_novel_path_unique",
"entityType": "indexes",
"table": "Chapter"
},
{
"columns": [
{
"value": "novelId",
"isExpression": false
},
{
"value": "position",
"isExpression": false
},
{
"value": "page",
"isExpression": false
},
{
"value": "id",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "chapterNovelIdIndex",
"entityType": "indexes",
"table": "Chapter"
},
{
"columns": [
{
"value": "novelId",
"isExpression": false
},
{
"value": "categoryId",
"isExpression": false
}
],
"isUnique": true,
"where": null,
"origin": "manual",
"name": "novel_category_unique",
"entityType": "indexes",
"table": "NovelCategory"
},
{
"columns": [
{
"value": "path",
"isExpression": false
},
{
"value": "pluginId",
"isExpression": false
}
],
"isUnique": true,
"where": null,
"origin": "manual",
"name": "novel_path_plugin_unique",
"entityType": "indexes",
"table": "Novel"
},
{
"columns": [
{
"value": "pluginId",
"isExpression": false
},
{
"value": "path",
"isExpression": false
},
{
"value": "id",
"isExpression": false
},
{
"value": "inLibrary",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "NovelIndex",
"entityType": "indexes",
"table": "Novel"
},
{
"columns": [
{
"value": "url",
"isExpression": false
}
],
"isUnique": true,
"where": null,
"origin": "manual",
"name": "repository_url_unique",
"entityType": "indexes",
"table": "Repository"
}
],
"renames": []
}
+2
View File
@@ -4,6 +4,7 @@ import m0000 from './20251222152612_past_mandrill/migration.sql';
import m0001 from './20260612232322_normal_saracen/migration.sql';
import m0002 from './20260719143427_long_moondragon/migration.sql';
import m0003 from './20260727081855_calm_chimera/migration.sql';
import m0004 from './20260811071655_parched_human_torch/migration.sql';
export default {
migrations: {
@@ -11,5 +12,6 @@ export default {
'20260612232322_normal_saracen': m0001,
'20260719143427_long_moondragon': m0002,
'20260727081855_calm_chimera': m0003,
'20260811071655_parched_human_torch': m0004,
},
};
+5
View File
@@ -1,6 +1,7 @@
const { defineConfig, globalIgnores } = require('eslint/config');
const expoConfig = require('eslint-config-expo/flat');
const { FlatCompat } = require('@eslint/eslintrc');
const tseslint = require('@typescript-eslint/eslint-plugin');
const compat = new FlatCompat({
baseDirectory: __dirname,
@@ -19,6 +20,7 @@ module.exports = defineConfig([
'.expo/**',
'dist/**',
'coverage/**',
'assets/**',
]),
expoConfig,
...compat
@@ -30,6 +32,9 @@ module.exports = defineConfig([
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: {
'@typescript-eslint': tseslint,
},
rules: {
'no-shadow': 'off',
'no-undef': 'off',
+3
View File
@@ -3,6 +3,9 @@ import { registerRootComponent } from 'expo';
import { AppRegistry, I18nManager } from 'react-native';
import { i18n } from './src/i18n/translations';
import { runHeadlessBackgroundTask } from './src/services/backgroundTasks';
import { installJsCrashHandler } from './src/services/crashLogs/installJsCrashHandler';
installJsCrashHandler();
AppRegistry.registerHeadlessTask(
'LNReaderBackgroundTask',
+2 -1
View File
@@ -16,6 +16,7 @@ const baseModuleNameMapper = {
'^@api/(.*)$': '<rootDir>/src/api/$1',
'^@type/(.*)$': '<rootDir>/src/type/$1',
'^@specs/(.*)$': '<rootDir>/specs/$1',
'^@modules/nitro-epub$': '<rootDir>/modules/nitro-epub/src/index',
'^@modules/nitro-tts$': '<rootDir>/modules/nitro-tts/src/index',
'^@modules/(.*)$': '<rootDir>/modules/$1',
'^@test-utils$': '<rootDir>/test/test-utils',
@@ -30,7 +31,7 @@ const baseTransform = {
const baseTransformIgnorePatterns = [
// 'node_modules/(?!(.pnpm/|@op-engineering|drizzle-orm|lodash-es|@babel/runtime))',
'node_modules/(?!(?:.pnpm/)?((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|color|@op-engineering|drizzle-orm|lodash-es))',
'node_modules/(?!(?:.pnpm/)?((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|@material/material-color-utilities|@pchmn/expo-material3-theme|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|color|@op-engineering|drizzle-orm|lodash-es))',
];
module.exports = {
@@ -22,7 +22,7 @@ dependencies {
implementation 'com.facebook.react:react-android'
implementation 'androidx.room:room-runtime:2.7.0'
implementation 'androidx.room:room-ktx:2.7.0'
implementation 'androidx.work:work-runtime-ktx:2.10.0'
implementation 'androidx.work:work-runtime-ktx:2.11.2'
kapt 'androidx.room:room-compiler:2.7.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
}
}
@@ -0,0 +1,30 @@
package expo.modules.nativebackgroundtasks
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
class AutomaticBackupScheduleWorker(
appContext: Context,
workerParams: WorkerParameters,
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
val title = inputData.getString(AutomaticBackupScheduler.TITLE)
?: AutomaticBackupScheduler.DEFAULT_TITLE
val description = inputData.getString(AutomaticBackupScheduler.DESCRIPTION)
?: AutomaticBackupScheduler.DEFAULT_DESCRIPTION
val directoryUri = inputData.getString(AutomaticBackupScheduler.DIRECTORY_URI)
return try {
BackgroundTaskScheduler.enqueueAutomaticBackup(
applicationContext,
title,
description,
directoryUri,
)
Result.success()
} catch (_: Exception) {
Result.retry()
}
}
}
@@ -0,0 +1,56 @@
package expo.modules.nativebackgroundtasks
import android.content.Context
import androidx.work.Data
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
object AutomaticBackupScheduler {
const val TITLE = "title"
const val DESCRIPTION = "description"
const val DIRECTORY_URI = "directoryUri"
const val DEFAULT_TITLE = "Local Backup"
const val DEFAULT_DESCRIPTION = "Preparing"
private const val WORK_NAME = "lnreader-automatic-backup"
private val allowedIntervals = setOf(6L, 12L, 24L, 48L, 168L)
fun schedule(
context: Context,
intervalHours: Long,
title: String,
description: String,
directoryUri: String?,
) {
require(intervalHours in allowedIntervals) {
"Unsupported automatic backup interval: $intervalHours"
}
val request =
PeriodicWorkRequestBuilder<AutomaticBackupScheduleWorker>(
intervalHours,
TimeUnit.HOURS,
)
.setInitialDelay(intervalHours, TimeUnit.HOURS)
.setInputData(
Data.Builder()
.putString(TITLE, title)
.putString(DESCRIPTION, description)
.putString(DIRECTORY_URI, directoryUri)
.build(),
)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_NAME,
ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE,
request,
)
}
fun cancel(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
}
}
@@ -5,13 +5,41 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
data class BackgroundTaskSummary(
val id: String,
val type: String,
val title: String,
val description: String,
val state: String,
val progress: Double?,
val progressText: String?,
val attempt: Int,
val createdAt: Long,
val updatedAt: Long,
)
@Dao
interface BackgroundTaskDao {
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insert(task: BackgroundTaskEntity)
@Query("SELECT * FROM background_tasks ORDER BY createdAt ASC")
suspend fun getAll(): List<BackgroundTaskEntity>
@Query(
"SELECT id, type, title, description, state, progress, progressText, " +
"attempt, createdAt, updatedAt " +
"FROM background_tasks " +
"WHERE state IN ('queued', 'running', 'paused') ORDER BY createdAt ASC",
)
suspend fun getActive(): List<BackgroundTaskSummary>
@Query(
"DELETE FROM background_tasks " +
"WHERE state IN ('succeeded', 'failed', 'cancelled') " +
"AND updatedAt < :cutoff",
)
suspend fun deleteOldTerminal(cutoff: Long)
@Query("DELETE FROM background_tasks WHERE id = :id AND state = :state")
suspend fun deleteIfState(id: String, state: String): Int
@Query("SELECT * FROM background_tasks WHERE id = :id LIMIT 1")
suspend fun get(id: String): BackgroundTaskEntity?
@@ -19,11 +47,42 @@ interface BackgroundTaskDao {
@Query("SELECT * FROM background_tasks WHERE type = :type AND state IN ('queued', 'running', 'paused') LIMIT 1")
suspend fun getActiveByType(type: String): BackgroundTaskEntity?
@Query("UPDATE background_tasks SET state = :state, updatedAt = :updatedAt WHERE id = :id")
suspend fun updateState(id: String, state: String, updatedAt: Long)
@Query("SELECT * FROM background_tasks WHERE queueName = :queueName AND state = 'queued' ORDER BY createdAt ASC")
suspend fun getQueuedByQueueName(queueName: String): List<BackgroundTaskEntity>
@Query(
"UPDATE background_tasks SET state = 'paused', updatedAt = :updatedAt " +
"WHERE id = :id AND state IN ('queued', 'running')",
)
suspend fun markPaused(id: String, updatedAt: Long): Int
@Query(
"UPDATE background_tasks SET state = 'queued', updatedAt = :updatedAt " +
"WHERE id = :id AND state = 'paused'",
)
suspend fun markQueued(id: String, updatedAt: Long): Int
@Query(
"UPDATE background_tasks SET state = 'cancelled', updatedAt = :updatedAt " +
"WHERE id = :id AND state IN ('queued', 'running', 'paused')",
)
suspend fun markCancelled(id: String, updatedAt: Long): Int
@Query("UPDATE background_tasks SET state = :state, updatedAt = :updatedAt WHERE id = :id AND state = 'running'")
suspend fun finishRunning(id: String, state: String, updatedAt: Long)
suspend fun finishRunning(id: String, state: String, updatedAt: Long): Int
@Query(
"UPDATE background_tasks " +
"SET state = :state, progress = :progress, progressText = :progressText, updatedAt = :updatedAt " +
"WHERE id = :id AND state = 'running'",
)
suspend fun finishRunningWithProgress(
id: String,
state: String,
progress: Double?,
progressText: String?,
updatedAt: Long,
): Int
@Query(
"""
@@ -48,11 +107,11 @@ interface BackgroundTaskDao {
maxConcurrentDownloads: Int,
): Int
@Query("UPDATE background_tasks SET progress = :progress, progressText = :progressText, updatedAt = :updatedAt WHERE id = :id")
suspend fun updateProgress(id: String, progress: Double?, progressText: String?, updatedAt: Long)
@Query("UPDATE background_tasks SET progress = :progress, progressText = :progressText, updatedAt = :updatedAt WHERE id = :id AND state = 'running'")
suspend fun updateProgress(id: String, progress: Double?, progressText: String?, updatedAt: Long): Int
@Query("UPDATE background_tasks SET checkpoint = :checkpoint, updatedAt = :updatedAt WHERE id = :id")
suspend fun updateCheckpoint(id: String, checkpoint: String?, updatedAt: Long)
@Query("UPDATE background_tasks SET checkpoint = :checkpoint, updatedAt = :updatedAt WHERE id = :id AND state = 'running'")
suspend fun updateCheckpoint(id: String, checkpoint: String?, updatedAt: Long): Int
@Query("UPDATE background_tasks SET workId = :workId, updatedAt = :updatedAt WHERE id = :id")
suspend fun assignWork(id: String, workId: String, updatedAt: Long)
@@ -1,12 +1,15 @@
package expo.modules.nativebackgroundtasks
import android.content.Context
import android.net.Uri
import android.provider.DocumentsContract
import androidx.work.Data
import androidx.work.Constraints
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.await
import java.util.UUID
object BackgroundTaskScheduler {
@@ -34,6 +37,21 @@ object BackgroundTaskScheduler {
return request.id
}
suspend fun cancel(context: Context, taskId: String, isRunning: Boolean) {
// A queued worker must finish as a no-op so WorkManager does not cancel
// every task chained after it.
if (!isRunning) return
val dao = BackgroundTaskDatabase.get(context).tasks()
// The running worker may delete the row as it consumes cancellation.
// Capture the queue before cancelling that worker.
val queueName = dao.get(taskId)?.queueName ?: return
WorkManager.getInstance(context).cancelAllWorkByTag(taskId).await()
// Cancelling running work also cancels its dependents, so rebuild the
// remaining queue after the worker has stopped.
dao.getQueuedByQueueName(queueName).forEach { enqueue(context, it.id) }
}
suspend fun enqueueLibraryUpdate(
context: Context,
title: String,
@@ -65,6 +83,62 @@ object BackgroundTaskScheduler {
return enqueue(context, task.id)
}
suspend fun enqueueAutomaticBackup(
context: Context,
title: String,
description: String,
directoryUri: String?,
): UUID? {
val dao = BackgroundTaskDatabase.get(context).tasks()
if (dao.getActiveByType(LOCAL_BACKUP_TASK_TYPE) != null) {
return null
}
val filename = "lnreader_backup_${System.currentTimeMillis()}.zip"
val destination = if (directoryUri != null) {
val treeUri = Uri.parse(directoryUri)
val documentId = DocumentsContract.getTreeDocumentId(treeUri)
val parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId)
DocumentsContract.createDocument(
context.contentResolver,
parentUri,
"application/zip",
filename,
)?.toString() ?: throw IllegalStateException(
"Could not create a backup in the selected directory",
)
} else {
val backupDirectory = context.getExternalFilesDir(null)?.resolve("Backups")
?: throw IllegalStateException("External files directory is unavailable")
if (!backupDirectory.exists() && !backupDirectory.mkdirs()) {
throw IllegalStateException("Could not create automatic backup directory")
}
backupDirectory.resolve(filename).absolutePath
}
val escapedDestination = destination
.replace("\\", "\\\\")
.replace("\"", "\\\"")
val now = System.currentTimeMillis()
val task = BackgroundTaskEntity(
id = UUID.randomUUID().toString(),
type = LOCAL_BACKUP_TASK_TYPE,
payload = """{"name":"$LOCAL_BACKUP_TASK_TYPE","data":{"destinationUri":"$escapedDestination","automatic":true}}""",
title = title,
description = description,
queueName = "lnreader-background-task:task:$LOCAL_BACKUP_TASK_TYPE",
state = BackgroundTaskState.QUEUED,
progress = null,
progressText = null,
checkpoint = null,
attempt = 0,
workId = null,
createdAt = now,
updatedAt = now,
)
dao.insert(task)
return enqueue(context, task.id)
}
private val NETWORK_TASKS = setOf(
LIBRARY_UPDATE_TASK_TYPE,
"DRIVE_BACKUP",
@@ -76,4 +150,5 @@ object BackgroundTaskScheduler {
)
private const val LIBRARY_UPDATE_TASK_TYPE = "UPDATE_LIBRARY"
private const val LOCAL_BACKUP_TASK_TYPE = "LOCAL_BACKUP"
}
@@ -8,6 +8,7 @@ import com.facebook.react.jstasks.HeadlessJsTaskConfig
class LNReaderHeadlessTaskService : HeadlessJsTaskService() {
override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig? {
val extras = intent?.extras ?: return null
if (!extras.containsKey(BackgroundTaskScheduler.TASK_ID)) return null
return HeadlessJsTaskConfig(
"LNReaderBackgroundTask",
Arguments.fromBundle(extras),
@@ -15,4 +16,4 @@ class LNReaderHeadlessTaskService : HeadlessJsTaskService() {
true,
)
}
}
}
@@ -8,6 +8,8 @@ import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
class LNReaderTaskWorker(
appContext: Context,
@@ -16,8 +18,12 @@ class LNReaderTaskWorker(
override suspend fun doWork(): Result {
val taskId = inputData.getString(BackgroundTaskScheduler.TASK_ID) ?: return Result.failure()
val dao = BackgroundTaskDatabase.get(applicationContext).tasks()
val task = dao.get(taskId) ?: return Result.failure()
if (task.state == BackgroundTaskState.CANCELLED || task.state == BackgroundTaskState.PAUSED) {
val task = dao.get(taskId) ?: return Result.success()
if (task.state == BackgroundTaskState.CANCELLED) {
consumeCancellation(dao, taskId)
return Result.success()
}
if (task.state == BackgroundTaskState.PAUSED) {
return Result.success()
}
@@ -28,71 +34,135 @@ class LNReaderTaskWorker(
MAX_CONCURRENT_DOWNLOADS,
)
if (claimed == 0) {
return if (dao.get(taskId)?.state == BackgroundTaskState.QUEUED) {
Result.retry()
} else {
Result.success()
return when (dao.get(taskId)?.state) {
BackgroundTaskState.QUEUED -> Result.retry()
BackgroundTaskState.CANCELLED -> {
consumeCancellation(dao, taskId)
Result.success()
}
else -> Result.success()
}
}
val runningTask = dao.get(taskId) ?: return Result.failure()
val runningTask = dao.get(taskId) ?: return Result.success()
if (runningTask.state != BackgroundTaskState.RUNNING) {
if (runningTask.state == BackgroundTaskState.CANCELLED) {
consumeCancellation(dao, taskId)
}
return Result.success()
}
setForeground(createForegroundInfo(runningTask))
val execution = TaskExecutionRegistry.register(taskId)
applicationContext.startService(
Intent(applicationContext, LNReaderHeadlessTaskService::class.java).apply {
putExtra("taskId", runningTask.id)
putExtra("type", runningTask.type)
putExtra("payload", runningTask.payload)
runningTask.checkpoint?.let { putExtra("checkpoint", it) }
},
)
return try {
applicationContext.startService(
Intent(applicationContext, LNReaderHeadlessTaskService::class.java).apply {
putExtra(BackgroundTaskScheduler.TASK_ID, runningTask.id)
},
)
when (val executionResult = execution.await()) {
TaskExecutionResult.Success -> {
val currentState = dao.get(taskId)?.state
if (currentState == BackgroundTaskState.CANCELLED) {
TaskNotificationFactory.dismiss(applicationContext, taskId)
return Result.success()
}
if (currentState == BackgroundTaskState.PAUSED) {
dao.get(taskId)?.let { TaskNotificationFactory.update(applicationContext, it) }
return Result.success()
}
dao.finishRunning(taskId, BackgroundTaskState.SUCCEEDED, System.currentTimeMillis())
dao.get(taskId)?.let {
TaskNotificationFactory.postTerminal(applicationContext, it)
if (dao.finishRunning(taskId, BackgroundTaskState.SUCCEEDED, System.currentTimeMillis()) > 0) {
postOwnedTerminal(taskId, dao, BackgroundTaskState.SUCCEEDED)
} else {
resultAfterLostOwnership(taskId, dao)
}
Result.success()
}
is TaskExecutionResult.Failure -> {
val currentState = dao.get(taskId)?.state
if (currentState == BackgroundTaskState.PAUSED || currentState == BackgroundTaskState.CANCELLED) {
return Result.success()
}
if (executionResult.shouldRetry) {
dao.updateState(taskId, BackgroundTaskState.QUEUED, System.currentTimeMillis())
Result.retry()
} else {
dao.updateState(taskId, BackgroundTaskState.FAILED, System.currentTimeMillis())
dao.get(taskId)?.let {
TaskNotificationFactory.postTerminal(applicationContext, it)
if (dao.finishRunning(taskId, BackgroundTaskState.QUEUED, System.currentTimeMillis()) > 0) {
Result.retry()
} else {
resultAfterLostOwnership(taskId, dao)
}
} else if (
dao.finishRunning(taskId, BackgroundTaskState.FAILED, System.currentTimeMillis()) > 0
) {
postOwnedTerminal(taskId, dao, BackgroundTaskState.FAILED)
Result.success()
} else {
resultAfterLostOwnership(taskId, dao)
}
}
}
} catch (error: CancellationException) {
val latestState = dao.get(taskId)?.state
if (latestState !in listOf(BackgroundTaskState.PAUSED, BackgroundTaskState.CANCELLED)) {
dao.updateState(taskId, BackgroundTaskState.QUEUED, System.currentTimeMillis())
withContext(NonCancellable) {
when (dao.get(taskId)?.state) {
BackgroundTaskState.CANCELLED -> consumeCancellation(dao, taskId)
BackgroundTaskState.RUNNING -> dao.finishRunning(
taskId,
BackgroundTaskState.QUEUED,
System.currentTimeMillis(),
)
else -> Unit
}
}
throw error
} catch (error: Exception) {
val now = System.currentTimeMillis()
val message = error.message ?: error.javaClass.simpleName
if (
dao.finishRunningWithProgress(
taskId,
BackgroundTaskState.FAILED,
null,
message,
now,
) > 0
) {
postOwnedTerminal(taskId, dao, BackgroundTaskState.FAILED)
Result.failure()
} else {
when (dao.get(taskId)?.state) {
BackgroundTaskState.CANCELLED -> {
consumeCancellation(dao, taskId)
Result.success()
}
BackgroundTaskState.PAUSED -> Result.success()
else -> Result.failure()
}
}
} finally {
TaskExecutionRegistry.cancel(taskId)
}
}
private suspend fun resultAfterLostOwnership(
taskId: String,
dao: BackgroundTaskDao,
): Result {
return when (dao.get(taskId)?.state) {
BackgroundTaskState.CANCELLED -> {
consumeCancellation(dao, taskId)
Result.success()
}
BackgroundTaskState.PAUSED -> {
dao.get(taskId)?.let { TaskNotificationFactory.update(applicationContext, it) }
Result.success()
}
else -> Result.success()
}
}
private suspend fun postOwnedTerminal(
taskId: String,
dao: BackgroundTaskDao,
state: String,
) {
val terminalTask = dao.get(taskId) ?: return
if (terminalTask.state != state) return
TaskNotificationFactory.postTerminal(applicationContext, terminalTask)
dao.deleteIfState(taskId, state)
}
private suspend fun consumeCancellation(dao: BackgroundTaskDao, taskId: String) {
if (dao.deleteIfState(taskId, BackgroundTaskState.CANCELLED) > 0) {
TaskNotificationFactory.dismiss(applicationContext, taskId)
}
}
private fun createForegroundInfo(task: BackgroundTaskEntity): ForegroundInfo {
val id = TaskNotificationFactory.notificationId(task.id)
val notification = TaskNotificationFactory.build(applicationContext, task)
@@ -57,35 +57,28 @@ class NativeBackgroundTasksModule : Module() {
AsyncFunction("getTasks") {
runBlocking(Dispatchers.IO) {
val result = mutableListOf<Map<String, Any?>>()
dao.getAll().forEach { task ->
result.add(mapOf(
"id" to task.id,
"type" to task.type,
"payload" to task.payload,
"title" to task.title,
"description" to task.description,
"state" to task.state,
"progress" to task.progress,
"progressText" to task.progressText,
"checkpoint" to task.checkpoint,
"attempt" to task.attempt,
"createdAt" to task.createdAt.toDouble(),
"updatedAt" to task.updatedAt.toDouble(),
))
}
result
dao.deleteOldTerminal(
System.currentTimeMillis() - TERMINAL_TASK_RETENTION_MS,
)
dao.getActive().map(::toSummary)
}
}
AsyncFunction("getTask") { taskId: String ->
runBlocking(Dispatchers.IO) {
dao.get(taskId)?.let(::toRecord)
}
}
AsyncFunction("pause") { taskId: String ->
runBlocking(Dispatchers.IO) {
requireTask(taskId)
dao.updateState(taskId, BackgroundTaskState.PAUSED, System.currentTimeMillis())
if (TaskExecutionRegistry.isActive(taskId)) {
emitInterruption(taskId, "pause")
if (dao.markPaused(taskId, System.currentTimeMillis()) > 0) {
if (TaskExecutionRegistry.isActive(taskId)) {
emitInterruption(taskId, "pause")
}
dao.get(taskId)?.let { TaskNotificationFactory.update(appContext.reactContext!!, it) }
}
dao.get(taskId)?.let { TaskNotificationFactory.update(appContext.reactContext!!, it) }
}
}
@@ -95,20 +88,29 @@ class NativeBackgroundTasksModule : Module() {
if (TaskExecutionRegistry.isActive(taskId)) {
throw IllegalStateException("Task is still pausing; try resuming again shortly")
}
dao.updateState(taskId, BackgroundTaskState.QUEUED, System.currentTimeMillis())
BackgroundTaskScheduler.enqueue(appContext.reactContext!!, taskId)
if (dao.markQueued(taskId, System.currentTimeMillis()) > 0) {
BackgroundTaskScheduler.enqueue(appContext.reactContext!!, taskId)
}
}
}
AsyncFunction("cancel") { taskId: String ->
runBlocking(Dispatchers.IO) {
requireTask(taskId)
dao.updateState(taskId, BackgroundTaskState.CANCELLED, System.currentTimeMillis())
if (TaskExecutionRegistry.isActive(taskId)) {
val task = requireTask(taskId)
val isRunning = task.state == BackgroundTaskState.RUNNING ||
TaskExecutionRegistry.isActive(taskId)
if (dao.markCancelled(taskId, System.currentTimeMillis()) == 0) {
TaskNotificationFactory.dismiss(appContext.reactContext!!, taskId)
return@runBlocking
}
if (isRunning) {
emitInterruption(taskId, "cancel")
}
dao.updateCheckpoint(taskId, null, System.currentTimeMillis())
BackgroundTaskScheduler.cancel(appContext.reactContext!!, taskId, isRunning)
TaskNotificationFactory.dismiss(appContext.reactContext!!, taskId)
if (!isRunning && task.state != BackgroundTaskState.QUEUED) {
dao.deleteIfState(taskId, BackgroundTaskState.CANCELLED)
}
}
}
@@ -136,22 +138,13 @@ class NativeBackgroundTasksModule : Module() {
val now = System.currentTimeMillis()
dao.updateCheckpoint(taskId, null, now)
dao.updateProgress(taskId, null, completionText, now)
dao.finishRunning(taskId, BackgroundTaskState.SUCCEEDED, now)
TaskExecutionRegistry.complete(taskId, TaskExecutionResult.Success)
}
}
AsyncFunction("fail") { taskId: String, error: String, shouldRetry: Boolean ->
runBlocking(Dispatchers.IO) {
val currentState = dao.get(taskId)?.state
if (currentState !in listOf(BackgroundTaskState.PAUSED, BackgroundTaskState.CANCELLED)) {
dao.updateProgress(taskId, null, error, System.currentTimeMillis())
dao.finishRunning(
taskId,
if (shouldRetry) BackgroundTaskState.QUEUED else BackgroundTaskState.FAILED,
System.currentTimeMillis(),
)
}
dao.updateProgress(taskId, null, error, System.currentTimeMillis())
TaskExecutionRegistry.complete(taskId, TaskExecutionResult.Failure(error, shouldRetry))
}
}
@@ -168,12 +161,55 @@ class NativeBackgroundTasksModule : Module() {
AsyncFunction("cancelLibraryUpdates") {
LibraryUpdateScheduler.cancel(appContext.reactContext!!)
}
AsyncFunction("scheduleAutomaticBackups") { intervalHours: Long, title: String, description: String, directoryUri: String ->
AutomaticBackupScheduler.schedule(
appContext.reactContext!!,
intervalHours,
title,
description,
directoryUri.ifEmpty { null },
)
}
AsyncFunction("cancelAutomaticBackups") {
AutomaticBackupScheduler.cancel(appContext.reactContext!!)
}
}
private suspend fun requireTask(taskId: String): BackgroundTaskEntity =
dao.get(taskId) ?: throw IllegalArgumentException("Unknown background task: $taskId")
private fun toSummary(task: BackgroundTaskSummary): Map<String, Any?> = mapOf(
"id" to task.id,
"type" to task.type,
"title" to task.title,
"description" to task.description,
"state" to task.state,
"progress" to task.progress,
"progressText" to task.progressText,
"attempt" to task.attempt,
"createdAt" to task.createdAt.toDouble(),
"updatedAt" to task.updatedAt.toDouble(),
)
private fun toRecord(task: BackgroundTaskEntity): Map<String, Any?> = mapOf(
"id" to task.id,
"type" to task.type,
"title" to task.title,
"description" to task.description,
"state" to task.state,
"progress" to task.progress,
"progressText" to task.progressText,
"attempt" to task.attempt,
"createdAt" to task.createdAt.toDouble(),
"updatedAt" to task.updatedAt.toDouble(),
"payload" to task.payload,
"checkpoint" to task.checkpoint,
)
companion object {
private const val TERMINAL_TASK_RETENTION_MS = 24 * 60 * 60 * 1000L
@Volatile
private var reactContextRef: WeakReference<ReactApplicationContext>? = null
@@ -14,26 +14,37 @@ class TaskActionReceiver : BroadcastReceiver() {
CoroutineScope(Dispatchers.IO).launch {
try {
val dao = BackgroundTaskDatabase.get(context).tasks()
dao.get(taskId) ?: return@launch
val task = dao.get(taskId) ?: return@launch
when (intent.action) {
TaskNotificationFactory.ACTION_PAUSE -> {
dao.updateState(taskId, BackgroundTaskState.PAUSED, System.currentTimeMillis())
if (TaskExecutionRegistry.isActive(taskId)) {
NativeBackgroundTasksModule.emitInterruption(taskId, "pause")
if (dao.markPaused(taskId, System.currentTimeMillis()) > 0) {
if (TaskExecutionRegistry.isActive(taskId)) {
NativeBackgroundTasksModule.emitInterruption(taskId, "pause")
}
dao.get(taskId)?.let { TaskNotificationFactory.update(context, it) }
}
dao.get(taskId)?.let { TaskNotificationFactory.update(context, it) }
}
TaskNotificationFactory.ACTION_RESUME -> {
if (TaskExecutionRegistry.isActive(taskId)) return@launch
dao.updateState(taskId, BackgroundTaskState.QUEUED, System.currentTimeMillis())
BackgroundTaskScheduler.enqueue(context, taskId)
if (dao.markQueued(taskId, System.currentTimeMillis()) > 0) {
BackgroundTaskScheduler.enqueue(context, taskId)
}
}
TaskNotificationFactory.ACTION_CANCEL -> {
dao.updateState(taskId, BackgroundTaskState.CANCELLED, System.currentTimeMillis())
if (TaskExecutionRegistry.isActive(taskId)) {
val isRunning = task.state == BackgroundTaskState.RUNNING ||
TaskExecutionRegistry.isActive(taskId)
if (dao.markCancelled(taskId, System.currentTimeMillis()) == 0) {
TaskNotificationFactory.dismiss(context, taskId)
return@launch
}
if (isRunning) {
NativeBackgroundTasksModule.emitInterruption(taskId, "cancel")
}
BackgroundTaskScheduler.cancel(context, taskId, isRunning)
TaskNotificationFactory.dismiss(context, taskId)
if (!isRunning && task.state != BackgroundTaskState.QUEUED) {
dao.deleteIfState(taskId, BackgroundTaskState.CANCELLED)
}
}
}
} finally {
@@ -41,4 +52,4 @@ class TaskActionReceiver : BroadcastReceiver() {
}
}
}
}
}
+2 -1
View File
@@ -1,5 +1,6 @@
import NativeBackgroundTasks, {
NativeBackgroundTaskRecord,
NativeBackgroundTaskSummary,
} from './src/NativeBackgroundTasksModule';
export default NativeBackgroundTasks;
export { NativeBackgroundTaskRecord };
export { NativeBackgroundTaskRecord, NativeBackgroundTaskSummary };
@@ -1,21 +1,27 @@
import { requireNativeModule } from 'expo-modules-core';
export type NativeBackgroundTaskRecord = {
export type NativeBackgroundTaskSummary = {
id: string;
type: string;
payload: string;
title: string;
description?: string;
state: string;
progress?: number;
progressText?: string;
checkpoint?: string;
attempt: number;
createdAt: number;
updatedAt: number;
};
export type NativeBackgroundTaskRecord = NativeBackgroundTaskSummary & {
payload: string;
checkpoint?: string;
};
type NativeBackgroundTasksModule = {
getTasks(): Promise<NativeBackgroundTaskSummary[]>;
getTask(taskId: string): Promise<NativeBackgroundTaskRecord | null>;
enqueue(
type: string,
payload: string,
@@ -24,7 +30,6 @@ type NativeBackgroundTasksModule = {
allowsDuplicates: boolean,
queueName: string,
): Promise<string>;
getTasks(): Promise<NativeBackgroundTaskRecord[]>;
pause(taskId: string): Promise<void>;
resume(taskId: string): Promise<void>;
cancel(taskId: string): Promise<void>;
@@ -42,6 +47,13 @@ type NativeBackgroundTasksModule = {
description: string,
): Promise<void>;
cancelLibraryUpdates(): Promise<void>;
scheduleAutomaticBackups(
intervalHours: number,
title: string,
description: string,
directoryUri: string,
): Promise<void>;
cancelAutomaticBackups(): Promise<void>;
};
export default requireNativeModule<NativeBackgroundTasksModule>(
@@ -0,0 +1,24 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}
group = 'expo.modules.nativecrashlogs'
version = '0.1.0'
android {
namespace "expo.modules.nativecrashlogs"
defaultConfig {
versionCode 1
versionName "0.1.0"
}
lintOptions {
abortOnError false
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.17.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0'
testImplementation 'junit:junit:4.13.2'
}
@@ -0,0 +1,19 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<provider
android:name="expo.modules.nativecrashlogs.CrashHandlerInitializationProvider"
android:authorities="${applicationId}.crash-handler-initializer"
android:exported="false"
android:initOrder="110" />
<provider
android:name="expo.modules.nativecrashlogs.CrashLogFileProvider"
android:authorities="${applicationId}.crash-logs"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/crash_log_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,57 @@
package expo.modules.nativecrashlogs
import android.content.Context
import android.os.Process
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.system.exitProcess
internal class CrashExceptionHandler private constructor(
private val applicationContext: Context,
private val defaultHandler: Thread.UncaughtExceptionHandler?,
) : Thread.UncaughtExceptionHandler {
override fun uncaughtException(thread: Thread, exception: Throwable) {
try {
val recordedJsCrash = CrashLogStore.readPendingCrash(applicationContext)
CrashLogStore.writePendingCrash(
applicationContext,
buildString {
appendLine("Native crash captured: ${timestamp()}")
appendLine("Thread: ${thread.name}")
appendLine()
append(exception.stackTraceToString())
if (recordedJsCrash != null) {
appendLine()
appendLine()
appendLine("Previously recorded JavaScript crash:")
append(recordedJsCrash)
}
},
)
} catch (_: Throwable) {
// The platform handler must still terminate the process normally.
} finally {
val handler = defaultHandler
if (handler != null) {
handler.uncaughtException(thread, exception)
} else {
Process.killProcess(Process.myPid())
exitProcess(10)
}
}
}
companion object {
fun install(context: Context) {
val currentHandler = Thread.getDefaultUncaughtExceptionHandler()
if (currentHandler is CrashExceptionHandler) return
Thread.setDefaultUncaughtExceptionHandler(
CrashExceptionHandler(context.applicationContext, currentHandler),
)
}
private fun timestamp(): String =
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.ROOT).format(Date())
}
}
@@ -0,0 +1,34 @@
package expo.modules.nativecrashlogs
import android.content.ContentProvider
import android.content.ContentValues
import android.database.Cursor
import android.net.Uri
class CrashHandlerInitializationProvider : ContentProvider() {
override fun onCreate(): Boolean {
context?.let(CrashExceptionHandler::install)
return true
}
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?,
): Cursor? = null
override fun getType(uri: Uri): String? = null
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?,
): Int = 0
}
@@ -0,0 +1,5 @@
package expo.modules.nativecrashlogs
import androidx.core.content.FileProvider
class CrashLogFileProvider : FileProvider(R.xml.crash_log_paths)
@@ -0,0 +1,15 @@
package expo.modules.nativecrashlogs
internal object CrashLogRedactor {
private val headerPattern = Regex(
pattern = "(?i)(authorization|cookie|set-cookie)(\\s*[:=]\\s*).*$",
options = setOf(RegexOption.MULTILINE),
)
private val urlQueryPattern = Regex(
pattern = "(?i)(https?://[^\\s?#]+)\\?[^\\s#\"')\\]]+",
)
fun redact(contents: String): String = contents
.replace(headerPattern, "$1$2[REDACTED]")
.replace(urlQueryPattern, "$1?[REDACTED]")
}
@@ -0,0 +1,48 @@
package expo.modules.nativecrashlogs
import android.content.Context
import java.io.File
internal object CrashLogStore {
private const val CRASH_DIRECTORY = "crash-reports"
private const val PENDING_CRASH_FILE = "last_crash.txt"
private const val SHARE_DIRECTORY = "crash-logs"
private const val SHARE_FILE = "lnreader_crash_logs.txt"
fun createShareFile(context: Context): File {
val directory = File(context.cacheDir, SHARE_DIRECTORY)
check(directory.exists() || directory.mkdirs()) {
"Failed to create crash log cache directory"
}
return File(directory, SHARE_FILE)
}
fun readPendingCrash(context: Context): String? {
val file = pendingCrashFile(context)
if (!file.isFile) return null
return file.readText().takeIf { it.isNotBlank() }
}
fun writePendingCrash(context: Context, contents: String) {
val file = pendingCrashFile(context)
val directory = file.parentFile
check(directory != null && (directory.exists() || directory.mkdirs())) {
"Failed to create crash report directory"
}
val temporaryFile = File(directory, "$PENDING_CRASH_FILE.tmp")
temporaryFile.writeText(contents)
if (!temporaryFile.renameTo(file)) {
file.writeText(contents)
temporaryFile.delete()
}
}
fun clearPendingCrash(context: Context) {
val file = pendingCrashFile(context)
check(!file.exists() || file.delete()) { "Failed to clear pending crash report" }
}
private fun pendingCrashFile(context: Context): File =
File(File(context.filesDir, CRASH_DIRECTORY), PENDING_CRASH_FILE)
}
@@ -0,0 +1,73 @@
package expo.modules.nativecrashlogs
import android.content.Context
import java.io.File
import java.util.concurrent.TimeUnit
internal object LogcatCollector {
private const val MAX_LOG_BYTES = 512 * 1024
private const val MAX_LOG_LINES = 2_000
private const val LOGCAT_TIMEOUT_SECONDS = 2L
fun collect(context: Context, requestedLevel: String): String {
val level = if (requestedLevel == "V") "V" else "E"
val output = File.createTempFile("lnreader-logcat-", ".tmp", context.cacheDir)
return try {
val process = ProcessBuilder(
"logcat",
"-d",
"-t",
MAX_LOG_LINES.toString(),
"-v",
"year",
"-v",
"zone",
"*:$level",
)
.redirectErrorStream(true)
.redirectOutput(output)
.start()
if (!process.waitFor(LOGCAT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
process.destroyForcibly()
}
readBounded(output)
} catch (error: Exception) {
"Logcat unavailable: ${error.message ?: error.javaClass.simpleName}"
} finally {
output.delete()
}
}
private fun readBounded(file: File): String {
if (!file.isFile || file.length() == 0L) return "No accessible logcat entries."
val wasTruncated = file.length() > MAX_LOG_BYTES
val bytes = file.inputStream().use { input ->
var bytesToSkip = if (wasTruncated) file.length() - MAX_LOG_BYTES else 0L
while (bytesToSkip > 0) {
val skipped = input.skip(bytesToSkip)
if (skipped <= 0) break
bytesToSkip -= skipped
}
val buffer = ByteArray(MAX_LOG_BYTES)
var totalRead = 0
while (totalRead < buffer.size) {
val count = input.read(buffer, totalRead, buffer.size - totalRead)
if (count == -1) break
totalRead += count
}
buffer.copyOf(totalRead)
}
val sanitized = bytes.toString(Charsets.UTF_8)
.lineSequence()
.joinToString("\n", transform = CrashLogRedactor::redact)
return if (wasTruncated) {
"[Older logcat entries truncated]\n$sanitized"
} else {
sanitized
}
}
}
@@ -0,0 +1,163 @@
package expo.modules.nativecrashlogs
import android.content.ClipData
import android.content.Context
import android.content.Intent
import android.os.Build
import android.webkit.WebView
import androidx.core.content.FileProvider
import expo.modules.kotlin.Promise
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class NativeCrashLogsModule : Module() {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun definition() = ModuleDefinition {
Name("NativeCrashLogs")
OnCreate {
appContext.reactContext?.let(CrashExceptionHandler::install)
}
OnDestroy {
coroutineScope.cancel()
}
AsyncFunction("shareCrashLogs") { report: String, logLevel: String, promise: Promise ->
coroutineScope.launch {
try {
val context = requireNotNull(appContext.reactContext) {
"React context is unavailable"
}
val file = CrashLogStore.createShareFile(context)
file.writeText(
CrashLogRedactor.redact(
report.take(MAX_REPORT_CHARACTERS).trimEnd(),
),
)
file.appendText("\n\n[App-accessible logcat]\n")
file.appendText(LogcatCollector.collect(context, logLevel))
withContext(Dispatchers.Main) {
shareFile(context, file)
}
promise.resolve(null)
} catch (error: Exception) {
promise.reject("ECRASHLOG", "Failed to share crash logs", error)
}
}
}
Function("recordJsCrash") { stackTrace: String ->
val context = requireNotNull(appContext.reactContext) {
"React context is unavailable"
}
CrashLogStore.writePendingCrash(context, stackTrace.take(MAX_REPORT_CHARACTERS))
}
AsyncFunction("getPendingCrash") { promise: Promise ->
coroutineScope.launch {
try {
val context = requireNotNull(appContext.reactContext) {
"React context is unavailable"
}
promise.resolve(CrashLogStore.readPendingCrash(context))
} catch (error: Exception) {
promise.reject("ECRASHREAD", "Failed to read pending crash", error)
}
}
}
AsyncFunction("clearPendingCrash") { promise: Promise ->
coroutineScope.launch {
try {
val context = requireNotNull(appContext.reactContext) {
"React context is unavailable"
}
CrashLogStore.clearPendingCrash(context)
promise.resolve(null)
} catch (error: Exception) {
promise.reject("ECRASHCLEAR", "Failed to clear pending crash", error)
}
}
}
AsyncFunction("getDebugInfo") { promise: Promise ->
coroutineScope.launch {
try {
val context = requireNotNull(appContext.reactContext) {
"React context is unavailable"
}
val webViewVersion = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
WebView.getCurrentWebViewPackage()?.versionName
} else {
null
}
promise.resolve(mapOf("webViewVersion" to webViewVersion))
} catch (error: Exception) {
promise.reject("EDEBUGINFO", "Failed to read native debug info", error)
}
}
}
Function("restartApp") {
val context = requireNotNull(appContext.reactContext) {
"React context is unavailable"
}
val launchIntent = requireNotNull(
context.packageManager.getLaunchIntentForPackage(context.packageName),
) { "Application launch intent is unavailable" }
try {
CrashLogStore.clearPendingCrash(context)
} catch (_: Exception) {
// Restart remains useful even if stale crash cleanup fails.
}
launchIntent.addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP,
)
val activity = appContext.currentActivity
if (activity == null) {
context.startActivity(launchIntent)
} else {
activity.runOnUiThread {
activity.finishAffinity()
activity.startActivity(launchIntent)
}
}
}
}
private fun shareFile(context: Context, file: java.io.File) {
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.crash-logs",
file,
)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_STREAM, uri)
clipData = ClipData.newRawUri("LNReader crash logs", uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val chooser = Intent.createChooser(shareIntent, "Share crash logs")
if (appContext.currentActivity == null) {
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(chooser)
} else {
appContext.currentActivity?.startActivity(chooser)
}
}
companion object {
private const val MAX_REPORT_CHARACTERS = 256 * 1024
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="crash_logs" path="crash-logs/" />
</paths>
@@ -0,0 +1,26 @@
package expo.modules.nativecrashlogs
import org.junit.Assert.assertEquals
import org.junit.Test
class CrashLogRedactorTest {
@Test
fun redactsSensitiveHeaders() {
val report = "Authorization: Bearer secret\nCookie=session-secret\nmessage"
assertEquals(
"Authorization: [REDACTED]\nCookie=[REDACTED]\nmessage",
CrashLogRedactor.redact(report),
)
}
@Test
fun redactsUrlQueries() {
val report = "Request failed: https://example.com/chapter?id=12&token=secret"
assertEquals(
"Request failed: https://example.com/chapter?[REDACTED]",
CrashLogRedactor.redact(report),
)
}
}
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["expo.modules.nativecrashlogs.NativeCrashLogsModule"]
}
}
+3
View File
@@ -0,0 +1,3 @@
import NativeCrashLogs from './src/NativeCrashLogsModule';
export default NativeCrashLogs;
+3
View File
@@ -0,0 +1,3 @@
{
"name": "native-crash-logs"
}
@@ -0,0 +1,18 @@
import { requireOptionalNativeModule } from 'expo-modules-core';
export type NativeCrashDebugInfo = {
webViewVersion: string | null;
};
export type NativeCrashLogsModule = {
shareCrashLogs(report: string, logLevel: 'E' | 'V'): Promise<void>;
recordJsCrash(stackTrace: string): void;
getPendingCrash(): Promise<string | null>;
clearPendingCrash(): Promise<void>;
getDebugInfo(): Promise<NativeCrashDebugInfo>;
restartApp(): void;
};
export default requireOptionalNativeModule<NativeCrashLogsModule>(
'NativeCrashLogs',
);
@@ -1,9 +1,11 @@
package expo.modules.nativefile
import android.app.Activity
import android.content.ContentResolver
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import com.facebook.react.bridge.BaseActivityEventListener
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.modules.network.CookieJarContainer
@@ -32,6 +34,7 @@ import java.io.FileWriter
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.UUID
import java.io.PushbackInputStream
import java.util.zip.GZIPInputStream
import kotlin.coroutines.coroutineContext
@@ -47,7 +50,11 @@ class NativeFileModule : Module() {
private val activityEventListener = object : BaseActivityEventListener() {
override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode != CREATE_DOCUMENT_REQUEST && requestCode != PICK_DOCUMENT_REQUEST) return
if (
requestCode != CREATE_DOCUMENT_REQUEST &&
requestCode != PICK_DOCUMENT_REQUEST &&
requestCode != PICK_DIRECTORY_REQUEST
) return
val promise = pendingDocumentPromise ?: return
pendingDocumentPromise = null
val uri = data?.data
@@ -62,7 +69,24 @@ class NativeFileModule : Module() {
} catch (_: SecurityException) {
// Some providers do not support persisted grants.
}
promise.resolve(uri.toString())
if (requestCode == PICK_DIRECTORY_REQUEST) {
val documentId = try {
DocumentsContract.getTreeDocumentId(uri)
} catch (_: IllegalArgumentException) {
uri.lastPathSegment.orEmpty()
}
promise.resolve(
mapOf(
"uri" to uri.toString(),
"name" to documentId
.substringAfterLast(':')
.substringAfterLast('/')
.ifEmpty { "Selected folder" },
),
)
} else {
promise.resolve(uri.toString())
}
}
}
@@ -97,9 +121,10 @@ class NativeFileModule : Module() {
filepath: String,
destPath: String,
onDone: (() -> Unit)? = null,
) {
): Long {
try {
val inputStream = getInputStream(filepath)
var copiedBytes = 0L
try {
val outputStream = getOutputStream(destPath)
try {
@@ -108,7 +133,9 @@ class NativeFileModule : Module() {
while (inputStream.read(buffer).also { length = it } > 0) {
coroutineContext.ensureActive()
outputStream.write(buffer, 0, length)
copiedBytes += length
}
outputStream.flush()
} finally {
outputStream.close()
}
@@ -118,11 +145,201 @@ class NativeFileModule : Module() {
if (onDone != null) {
onDone()
}
return copiedBytes
} catch (e: IOException) {
throw Exception("Failed to copy file from '$filepath' to '$destPath': ${e.message}")
}
}
private fun contentResolver(): ContentResolver =
reactContext?.contentResolver
?: throw IOException("React context is unavailable")
private suspend fun copyToOutputStream(sourcePath: String, outputStream: OutputStream): Long {
var copiedBytes = 0L
getInputStream(sourcePath).use { inputStream ->
outputStream.use { output ->
val buffer = ByteArray(BUFFER_SIZE)
var length: Int
while (inputStream.read(buffer).also { length = it } > 0) {
coroutineContext.ensureActive()
output.write(buffer, 0, length)
copiedBytes += length
}
output.flush()
}
}
return copiedBytes
}
private fun resolveDirectoryFile(directoryUri: String): File {
val uri = Uri.parse(directoryUri)
val directory = if (uri.scheme == null) {
File(directoryUri)
} else if (uri.scheme == ContentResolver.SCHEME_FILE) {
File(uri.path ?: throw IOException("Invalid directory URI: '$directoryUri'"))
} else {
throw IOException("Unsupported filesystem directory URI: '$directoryUri'")
}
if (!directory.isDirectory) {
throw IOException("Destination directory does not exist: '$directoryUri'")
}
return directory
}
private suspend fun copyFileToFilesystemDirectory(
sourcePath: String,
directoryUri: String,
fileName: String,
replace: Boolean,
): Map<String, Any> {
val directory = resolveDirectoryFile(directoryUri)
val destination = File(directory, fileName)
if (destination.exists() && !replace) {
throw IOException("File already exists: ${destination.absolutePath}")
}
val staging = File(directory, ".$fileName.${UUID.randomUUID()}.tmp")
val backup = File(directory, ".$fileName.${UUID.randomUUID()}.bak")
try {
val copiedBytes = FileOutputStream(staging).use { output ->
copyToOutputStream(sourcePath, output)
}
if (staging.length() != copiedBytes) {
throw IOException("Copied file size does not match the source stream")
}
val hadDestination = destination.exists()
if (hadDestination && !destination.renameTo(backup)) {
throw IOException("Could not stage the existing destination for replacement")
}
if (!staging.renameTo(destination)) {
if (hadDestination) {
backup.renameTo(destination)
}
throw IOException("Could not move the completed copy into the destination")
}
if (backup.exists() && !backup.delete()) {
throw IOException("Could not remove the replaced destination backup")
}
return mapOf("uri" to destination.absolutePath, "size" to copiedBytes)
} finally {
staging.delete()
if (backup.exists() && !destination.exists()) {
backup.renameTo(destination)
}
}
}
private fun resolveTreeDirectoryUri(treeUri: Uri): Uri {
if (!DocumentsContract.isTreeUri(treeUri)) {
throw IOException("Destination is not a Storage Access Framework directory: '$treeUri'")
}
val documentId = DocumentsContract.getTreeDocumentId(treeUri)
return DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId)
}
private fun findChildDocument(treeUri: Uri, directoryUri: Uri, fileName: String): Uri? {
val resolver = contentResolver()
val documentId = DocumentsContract.getDocumentId(directoryUri)
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, documentId)
val projection = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
)
resolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
while (cursor.moveToNext()) {
if (cursor.getString(1) == fileName) {
return DocumentsContract.buildDocumentUriUsingTree(treeUri, cursor.getString(0))
}
}
}
return null
}
private fun renameDocument(documentUri: Uri, displayName: String): Uri =
DocumentsContract.renameDocument(contentResolver(), documentUri, displayName)
?: throw IOException("Could not rename destination document to '$displayName'")
private suspend fun copyFileToSafDirectory(
sourcePath: String,
directoryUriString: String,
fileName: String,
mimeType: String,
replace: Boolean,
): Map<String, Any> {
val resolver = contentResolver()
val treeUri = Uri.parse(directoryUriString)
val directoryUri = resolveTreeDirectoryUri(treeUri)
val existing = findChildDocument(treeUri, directoryUri, fileName)
if (existing != null && !replace) {
throw IOException("File already exists: $fileName")
}
val token = UUID.randomUUID().toString()
val stagingName = "$fileName.lnreader-$token.tmp"
val backupName = "$fileName.lnreader-$token.bak"
var stagingUri = DocumentsContract.createDocument(
resolver,
directoryUri,
mimeType,
stagingName,
) ?: throw IOException("Could not create a temporary destination document")
var backupUri: Uri? = null
var completedUri: Uri? = null
try {
val copiedBytes = copyToOutputStream(
sourcePath,
resolver.openOutputStream(stagingUri, writeAccessByAPILevel)
?: throw IOException("Could not open the temporary destination document"),
)
if (existing != null) {
backupUri = renameDocument(existing, backupName)
}
try {
completedUri = renameDocument(stagingUri, fileName)
stagingUri = completedUri
} catch (error: Exception) {
backupUri?.let {
try {
renameDocument(it, fileName)
backupUri = null
} catch (_: Exception) {
// Keep the original replacement error.
}
}
throw error
}
backupUri?.let {
try {
DocumentsContract.deleteDocument(resolver, it)
} catch (_: Exception) {
// The completed destination is already in place.
}
backupUri = null
}
return mapOf("uri" to completedUri.toString(), "size" to copiedBytes)
} finally {
if (completedUri == null) {
try {
DocumentsContract.deleteDocument(resolver, stagingUri)
} catch (_: Exception) {
// Preserve the original copy or replacement error.
}
}
if (completedUri == null) backupUri?.let {
try {
renameDocument(it, fileName)
} catch (_: Exception) {
// Preserve the original copy or replacement error.
}
}
}
}
private suspend fun deleteRecursive(fileOrDirectory: File) {
coroutineContext.ensureActive()
if (fileOrDirectory.isDirectory) {
@@ -195,6 +412,14 @@ class NativeFileModule : Module() {
)
}
AsyncFunction("pickDirectory") { promise: Promise ->
launchDocumentIntent(
Intent(Intent.ACTION_OPEN_DOCUMENT_TREE),
PICK_DIRECTORY_REQUEST,
promise,
)
}
AsyncFunction("writeFile") { path: String, content: String, promise: Promise ->
coroutineScope.launch {
try {
@@ -232,6 +457,25 @@ class NativeFileModule : Module() {
}
}
AsyncFunction("copyFileToDirectory") { sourcePath: String, directoryUri: String, fileName: String, mimeType: String, replace: Boolean, promise: Promise ->
coroutineScope.launch {
try {
require(fileName.isNotBlank() && fileName != "." && fileName != ".." && !fileName.contains('/') && !fileName.contains('\\')) {
"Invalid destination file name"
}
val uri = Uri.parse(directoryUri)
val result = if (uri.scheme == ContentResolver.SCHEME_CONTENT) {
copyFileToSafDirectory(sourcePath, directoryUri, fileName, mimeType, replace)
} else {
copyFileToFilesystemDirectory(sourcePath, directoryUri, fileName, replace)
}
promise.resolve(result)
} catch (e: Exception) {
rejectFileOperation(promise, "copy into directory", directoryUri, e)
}
}
}
AsyncFunction("moveFile") { filepath: String, destPath: String, promise: Promise ->
coroutineScope.launch {
try {
@@ -262,6 +506,9 @@ class NativeFileModule : Module() {
coroutineScope.launch {
try {
val file = File(filepath)
if (file.exists() && !file.isDirectory) {
throw IOException("A file already exists at the directory path")
}
if (!file.exists() && !file.mkdirs()) {
throw IOException("Directory could not be created")
}
@@ -353,14 +600,21 @@ class NativeFileModule : Module() {
}
}
Constant("DocumentDirectoryPath") {
val context = reactContext ?: appContext.currentActivity
context?.filesDir?.absolutePath.orEmpty()
}
Constant("ExternalDirectoryPath") {
val externalDirectory = reactContext?.getExternalFilesDir(null)
externalDirectory?.absolutePath ?: ""
val context = reactContext ?: appContext.currentActivity
val directory = context?.getExternalFilesDir(null) ?: context?.filesDir
directory?.absolutePath.orEmpty()
}
Constant("ExternalCachesDirectoryPath") {
val externalCachesDirectory = reactContext?.externalCacheDir
externalCachesDirectory?.absolutePath ?: ""
val context = reactContext ?: appContext.currentActivity
val directory = context?.externalCacheDir ?: context?.cacheDir
directory?.absolutePath.orEmpty()
}
}
@@ -386,5 +640,6 @@ class NativeFileModule : Module() {
companion object {
private const val CREATE_DOCUMENT_REQUEST = 48120
private const val PICK_DOCUMENT_REQUEST = 48121
private const val PICK_DIRECTORY_REQUEST = 48122
}
}
}
@@ -17,6 +17,55 @@ public class NativeFileModule: Module {
try FileManager.default.copyItem(atPath: sourcePath, toPath: destPath)
}
AsyncFunction("copyFileToDirectory") { (sourcePath: String, directoryUri: String, fileName: String, mimeType: String, replace: Bool) -> [String: Any] in
_ = mimeType
guard !fileName.isEmpty,
fileName != ".",
fileName != "..",
!fileName.contains("/"),
!fileName.contains("\\") else {
throw NSError(
domain: "NativeFile",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Invalid destination file name"]
)
}
let fileManager = FileManager.default
let directoryURL: URL
if let parsedURL = URL(string: directoryUri), parsedURL.isFileURL {
directoryURL = parsedURL
} else {
directoryURL = URL(fileURLWithPath: directoryUri, isDirectory: true)
}
let sourceURL = URL(fileURLWithPath: sourcePath)
let destinationURL = directoryURL.appendingPathComponent(fileName)
let stagingURL = directoryURL.appendingPathComponent(".\(fileName).\(UUID().uuidString).tmp")
guard fileManager.fileExists(atPath: directoryURL.path) else {
throw NSError(
domain: "NativeFile",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "Destination directory does not exist"]
)
}
if fileManager.fileExists(atPath: destinationURL.path) && !replace {
throw CocoaError(.fileWriteFileExists)
}
defer { try? fileManager.removeItem(at: stagingURL) }
try fileManager.copyItem(at: sourceURL, to: stagingURL)
let attributes = try fileManager.attributesOfItem(atPath: stagingURL.path)
let copiedSize = (attributes[.size] as? NSNumber)?.int64Value ?? 0
if fileManager.fileExists(atPath: destinationURL.path) {
_ = try fileManager.replaceItemAt(destinationURL, withItemAt: stagingURL)
} else {
try fileManager.moveItem(at: stagingURL, to: destinationURL)
}
return ["uri": destinationURL.absoluteString, "size": copiedSize]
}
Function("moveFile") { (sourcePath: String, destPath: String) in
try FileManager.default.moveItem(atPath: sourcePath, toPath: destPath)
}
@@ -52,6 +101,11 @@ public class NativeFileModule: Module {
promise.reject("NOT_IMPLEMENTED", "downloadFile is not implemented on iOS")
}
Constant("DocumentDirectoryPath") {
let paths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)
return paths.first ?? ""
}
Constant("ExternalDirectoryPath") {
let paths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)
return paths.first ?? ""
+20 -1
View File
@@ -6,14 +6,33 @@ export type ReadDirResult = {
isDirectory: boolean;
};
export type DirectorySelection = {
uri: string;
name: string;
};
export type FileCopyResult = {
uri: string;
size: number;
};
type NativeFileModule = {
DocumentDirectoryPath: string;
ExternalDirectoryPath: string;
ExternalCachesDirectoryPath: string;
createDocument(filename: string, mimeType: string): Promise<string>;
pickDocument(mimeType: string): Promise<string>;
pickDirectory(): Promise<DirectorySelection>;
writeFile(path: string, content: string): Promise<void>;
readFile(path: string): Promise<string>;
copyFile(filepath: string, destPath: string): Promise<void>;
copyFileToDirectory(
sourcePath: string,
directoryUri: string,
fileName: string,
mimeType: string,
replace: boolean,
): Promise<FileCopyResult>;
moveFile(filepath: string, destPath: string): Promise<void>;
exists(filepath: string): Promise<boolean>;
mkdir(filepath: string): Promise<void>;
@@ -28,4 +47,4 @@ type NativeFileModule = {
): Promise<void>;
};
export default requireNativeModule<NativeFileModule>('NativeFile');
export default requireNativeModule<NativeFileModule>('NativeFile');
@@ -0,0 +1,18 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}
group = 'expo.modules.nativesharereceiver'
version = '0.1.0'
android {
namespace "expo.modules.nativesharereceiver"
defaultConfig {
versionCode 1
versionName "0.1.0"
}
lintOptions {
abortOnError false
}
}
@@ -0,0 +1,37 @@
package expo.modules.nativesharereceiver
import android.content.Intent
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class NativeShareReceiverModule : Module() {
override fun definition() = ModuleDefinition {
Name("NativeShareReceiver")
Events("SharedText")
Function("getInitialSharedText") {
val intent = appContext.currentActivity?.intent
val text = extractSharedText(intent)
if (text != null) {
// Consume the share so JS reloads (Fast Refresh, OTA) that re-read
// the activity intent don't re-deliver the same text.
intent?.removeExtra(Intent.EXTRA_TEXT)
}
text
}
OnNewIntent { intent ->
extractSharedText(intent)?.let { text ->
sendEvent("SharedText", mapOf("text" to text))
}
}
}
private fun extractSharedText(intent: Intent?): String? {
if (intent?.action != Intent.ACTION_SEND) {
return null
}
return intent.getStringExtra(Intent.EXTRA_TEXT)
}
}
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["expo.modules.nativesharereceiver.NativeShareReceiverModule"]
}
}
+2
View File
@@ -0,0 +1,2 @@
import NativeShareReceiver from './src/NativeShareReceiverModule';
export default NativeShareReceiver;
@@ -0,0 +1,3 @@
{
"name": "native-share-receiver"
}
@@ -0,0 +1,13 @@
import { NativeModule, requireNativeModule } from 'expo-modules-core';
export type NativeShareReceiverEvents = {
SharedText: (payload: { text: string }) => void;
};
declare class NativeShareReceiverModule extends NativeModule<NativeShareReceiverEvents> {
getInitialSharedText(): string | null;
}
export default requireNativeModule<NativeShareReceiverModule>(
'NativeShareReceiver',
);
@@ -13,8 +13,6 @@ import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
class NativeZipArchiveModule : Module() {
private val BUFFER_SIZE = 4096
private fun zipProcess(sourceDirPath: String, zos: ZipOutputStream) {
val sourceDir = File(sourceDirPath)
sourceDir.walkBottomUp().filter { it.isFile }.forEach { file ->
@@ -23,10 +21,8 @@ class NativeZipArchiveModule : Module() {
val entry = ZipEntry("$zipFileName${(if (file.isDirectory) "/" else "")}")
zos.putNextEntry(entry)
file.inputStream().use { fis ->
fis.copyTo(zos, BUFFER_SIZE)
fis.close()
fis.copyTo(zos, COPY_BUFFER_SIZE)
}
Thread.yield()
}
}
@@ -41,9 +37,8 @@ class NativeZipArchiveModule : Module() {
val newFile = File(distDirPath, zipEntry.name)
newFile.parentFile?.mkdirs()
zis.getInputStream(zipEntry).use { inputStream ->
FileOutputStream(newFile).use { fos -> inputStream.copyTo(fos, BUFFER_SIZE) }
FileOutputStream(newFile).use { fos -> inputStream.copyTo(fos, COPY_BUFFER_SIZE) }
}
Thread.yield()
}
}
promise.resolve(null)
@@ -80,8 +75,7 @@ class NativeZipArchiveModule : Module() {
.forEach { zipEntry ->
val newFile = File(distDirPath, zipEntry.name)
newFile.parentFile?.mkdirs()
FileOutputStream(newFile).use { fos -> zis.copyTo(fos, BUFFER_SIZE) }
Thread.yield()
FileOutputStream(newFile).use { fos -> zis.copyTo(fos, COPY_BUFFER_SIZE) }
}
}
if (connection.responseCode == 200) {
@@ -121,4 +115,8 @@ class NativeZipArchiveModule : Module() {
}
}
private companion object {
const val COPY_BUFFER_SIZE = 64 * 1024
}
}
+1
View File
@@ -6,6 +6,7 @@ Modules.
## Capabilities
- Parse an extracted EPUB into novel metadata and chapter paths.
- Normalize raster-only SVG image wrappers into HTML images during import.
- Export downloaded chapter files as an EPUB 3 archive.
- Copy local chapter images into the archive and remove missing images.
- Generate EPUB navigation, NCX compatibility navigation, metadata, CSS, and
@@ -8,6 +8,7 @@ set (CMAKE_CXX_STANDARD 20)
# Define C++ library and add all sources
add_library(${PACKAGE_NAME} SHARED
src/main/cpp/cpp-adapter.cpp
../cpp/import/EpubChapterNormalizer.cpp
../cpp/import/EpubParser.cpp
../cpp/export/EpubContentProcessor.cpp
../cpp/export/EpubDocuments.cpp
@@ -0,0 +1,135 @@
#include "EpubChapterNormalizer.hpp"
#include "../pugixml.hpp"
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <string>
#include <vector>
namespace {
std::string getLocalName(const std::string& qualifiedName) {
const size_t separator = qualifiedName.find(':');
return separator == std::string::npos
? qualifiedName
: qualifiedName.substr(separator + 1);
}
std::string toLower(std::string value) {
std::transform(value.begin(), value.end(), value.begin(), [](char character) {
return static_cast<char>(
std::tolower(static_cast<unsigned char>(character)));
});
return value;
}
std::string trim(std::string value) {
const auto isNotWhitespace = [](char character) {
return !std::isspace(static_cast<unsigned char>(character));
};
const auto start = std::find_if(value.begin(), value.end(), isNotWhitespace);
const auto end = std::find_if(value.rbegin(), value.rend(), isNotWhitespace)
.base();
return start < end ? std::string(start, end) : "";
}
std::string getImageReference(const pugi::xml_node& image) {
for (const pugi::xml_attribute attribute : image.attributes()) {
if (toLower(getLocalName(attribute.name())) == "href") {
return attribute.value();
}
}
return "";
}
void collectSvgNodes(const pugi::xml_node& parent,
std::vector<pugi::xml_node>& svgNodes) {
for (const pugi::xml_node child : parent.children()) {
if (child.type() != pugi::node_element) {
continue;
}
if (toLower(getLocalName(child.name())) == "svg") {
svgNodes.push_back(child);
}
collectSvgNodes(child, svgNodes);
}
}
bool normalizeImageWrapper(const pugi::xml_node& svg) {
std::vector<pugi::xml_node> images;
std::string description;
for (const pugi::xml_node child : svg.children()) {
if (child.type() != pugi::node_element) {
continue;
}
const std::string childName = toLower(getLocalName(child.name()));
if (childName == "image") {
if (getImageReference(child).empty()) {
return false;
}
images.push_back(child);
} else if (childName == "title" || childName == "desc") {
if (description.empty()) {
description = trim(child.text().as_string());
}
} else {
return false;
}
}
if (images.empty()) {
return false;
}
pugi::xml_node parent = svg.parent();
for (const pugi::xml_node image : images) {
pugi::xml_node htmlImage = parent.insert_child_before("img", svg);
for (const pugi::xml_attribute attribute : image.attributes()) {
if (toLower(getLocalName(attribute.name())) != "href") {
htmlImage.append_attribute(attribute.name()) = attribute.value();
}
}
htmlImage.append_attribute("src") = getImageReference(image).c_str();
if (!description.empty() && !htmlImage.attribute("alt")) {
htmlImage.append_attribute("alt") = description.c_str();
}
}
return parent.remove_child(svg);
}
} // namespace
bool normalizeEpubChapter(const std::string& chapterPath) {
pugi::xml_document document;
if (!document.load_file(chapterPath.c_str(), pugi::parse_full)) {
return false;
}
std::vector<pugi::xml_node> svgNodes;
collectSvgNodes(document, svgNodes);
bool changed = false;
for (const pugi::xml_node svg : svgNodes) {
changed = normalizeImageWrapper(svg) || changed;
}
if (!changed) {
return false;
}
const std::string temporaryPath = chapterPath + ".lnreader-normalized";
const unsigned int format = pugi::format_raw | pugi::format_no_declaration;
if (!document.save_file(temporaryPath.c_str(), "", format)) {
std::remove(temporaryPath.c_str());
return false;
}
if (std::rename(temporaryPath.c_str(), chapterPath.c_str()) != 0) {
std::remove(temporaryPath.c_str());
return false;
}
return true;
}
@@ -0,0 +1,5 @@
#pragma once
#include <string>
bool normalizeEpubChapter(const std::string& chapterPath);
+207 -41
View File
@@ -1,11 +1,16 @@
#include <string>
#include "../pugixml.hpp"
#include <iostream>
#include <stdexcept>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include "EpubChapterNormalizer.hpp"
#include "EpubParser.hpp"
#include <sstream>
namespace {
std::string join(const std::string &folder_path, const std::string &child_path)
{
std::string full_path = folder_path;
@@ -62,11 +67,118 @@ std::string getParentPath(const std::string &path)
return path.substr(0, pos);
}
std::string getLocalName(const std::string &qualified_name)
{
size_t separator = qualified_name.find(':');
return separator == std::string::npos
? qualified_name
: qualified_name.substr(separator + 1);
}
bool hasLocalName(const pugi::xml_node &node, const char *local_name)
{
return getLocalName(node.name()) == local_name;
}
pugi::xml_node childByLocalName(const pugi::xml_node &parent, const char *local_name)
{
for (pugi::xml_node child : parent.children())
{
if (hasLocalName(child, local_name))
{
return child;
}
}
return {};
}
void collectDescendantsByLocalName(const pugi::xml_node &parent,
const char *local_name,
std::vector<pugi::xml_node> &result)
{
for (pugi::xml_node child : parent.children())
{
if (hasLocalName(child, local_name))
{
result.push_back(child);
}
collectDescendantsByLocalName(child, local_name, result);
}
}
std::string findImageReference(const pugi::xml_node &node)
{
std::string node_name = getLocalName(node.name());
if (node_name == "img" || node_name == "image")
{
for (const char *attribute_name : {"src", "href", "xlink:href"})
{
std::string reference = node.attribute(attribute_name).as_string();
if (!reference.empty())
{
size_t fragment = reference.find('#');
return fragment == std::string::npos
? reference
: reference.substr(0, fragment);
}
}
}
for (pugi::xml_node child : node.children())
{
std::string reference = findImageReference(child);
if (!reference.empty())
{
return reference;
}
}
return "";
}
std::string findCoverImagePath(
const std::string &cover_document_path,
const std::unordered_set<std::string> &image_paths)
{
pugi::xml_document cover_document;
if (!cover_document.load_file(cover_document_path.c_str()))
{
return "";
}
std::string image_reference = findImageReference(cover_document);
if (image_reference.empty())
{
return "";
}
std::string image_path = join(getParentPath(cover_document_path), image_reference);
return image_paths.count(image_path) ? image_path : "";
}
bool hasProperty(const std::string &properties, const std::string &property)
{
std::stringstream property_stream(properties);
std::string value;
while (property_stream >> value)
{
if (value == property)
{
return true;
}
}
return false;
}
std::string find_toc_href(const pugi::xml_document &opf_doc)
{
auto manifest = opf_doc.child("package").child("manifest");
for (pugi::xml_node item : manifest.children("item"))
pugi::xml_node package = childByLocalName(opf_doc, "package");
pugi::xml_node manifest = childByLocalName(package, "manifest");
for (pugi::xml_node item : manifest.children())
{
if (!hasLocalName(item, "item"))
continue;
std::string media_type = item.attribute("media-type").value();
std::string id = item.attribute("id").value();
if (media_type == "application/x-dtbncx+xml" || id == "ncx" || id == "nav")
@@ -80,9 +192,12 @@ std::string find_toc_href(const pugi::xml_document &opf_doc)
void parse_navele_recursive(const pugi::xml_node &parent,
std::unordered_map<std::string, std::string> &href_to_label, std::string &nav_folder)
{
for (pugi::xml_node li : parent.children("li"))
for (pugi::xml_node li : parent.children())
{
pugi::xml_node a = li.child("a");
if (!hasLocalName(li, "li"))
continue;
pugi::xml_node a = childByLocalName(li, "a");
if (a)
{
std::string href = a.attribute("href").as_string();
@@ -96,7 +211,7 @@ void parse_navele_recursive(const pugi::xml_node &parent,
href_to_label[join(nav_folder, href)] = label;
}
if (pugi::xml_node sublist = li.child("ol"))
if (pugi::xml_node sublist = childByLocalName(li, "ol"))
{
parse_navele_recursive(sublist, href_to_label, nav_folder);
}
@@ -111,11 +226,11 @@ void parse_nav_xhtml(const std::string &nav_path,
if (!nav_doc.load_file(nav_path.c_str()))
return;
for (pugi::xpath_node nav : nav_doc.select_nodes("//nav"))
std::vector<pugi::xml_node> nav_nodes;
collectDescendantsByLocalName(nav_doc, "nav", nav_nodes);
for (pugi::xml_node node : nav_nodes)
{
pugi::xml_node node = nav.node();
std::string nav_type = node.attribute("epub:type").as_string();
pugi::xml_node ol = node.child("ol");
pugi::xml_node ol = childByLocalName(node, "ol");
if (ol)
{
parse_navele_recursive(ol, path_to_label, nav_folder);
@@ -126,15 +241,19 @@ void parse_nav_xhtml(const std::string &nav_path,
void parse_navpoint_recursive(const pugi::xml_node &navPoint,
std::unordered_map<std::string, std::string> &result, std::string &ncx_folder)
{
for (pugi::xml_node point : navPoint.children("navPoint"))
for (pugi::xml_node point : navPoint.children())
{
if (!hasLocalName(point, "navPoint"))
continue;
std::string label;
pugi::xml_node labelNode = point.child("navLabel").child("text");
pugi::xml_node labelNode = childByLocalName(
childByLocalName(point, "navLabel"), "text");
if (labelNode)
label = labelNode.text().as_string();
std::string src;
pugi::xml_node contentNode = point.child("content");
pugi::xml_node contentNode = childByLocalName(point, "content");
if (contentNode)
src = contentNode.attribute("src").as_string();
@@ -157,7 +276,8 @@ void parse_toc_ncx(const std::string &ncx_path, std::unordered_map<std::string,
if (!doc.load_file(ncx_path.c_str()))
return;
pugi::xml_node navMap = doc.child("ncx").child("navMap");
pugi::xml_node navMap = childByLocalName(
childByLocalName(doc, "ncx"), "navMap");
if (!navMap)
return;
@@ -172,38 +292,42 @@ void parse_opf_from_folder(const std::string &base_dir,
std::string opf_dir = getParentPath(opf_path);
pugi::xml_document opf_doc;
if (!opf_doc.load_file(opf_path.c_str()))
return;
std::string version;
pugi::xml_node package = opf_doc.child("package");
if (package)
{
version = package.attribute("version").as_string();
}
throw std::runtime_error("Failed to load EPUB package document");
pugi::xml_node package = childByLocalName(opf_doc, "package");
if (!package)
throw std::runtime_error("EPUB package document is missing package");
std::unordered_map<std::string, std::string> path_to_label;
std::string toc_href = find_toc_href(opf_doc);
if (toc_href.find("ncx") != std::string::npos)
if (!toc_href.empty() && toc_href.find("ncx") != std::string::npos)
{
std::string ncx_path = join(opf_dir, toc_href);
parse_toc_ncx(ncx_path, path_to_label);
}
else
else if (!toc_href.empty())
{
std::string nav_path = join(opf_dir, toc_href);
parse_nav_xhtml(nav_path, path_to_label);
}
auto metadata = opf_doc.child("package").child("metadata");
meta_out.name = metadata.child("dc:title").text().as_string();
meta_out.author = metadata.child("dc:creator").text().as_string();
meta_out.artist = metadata.child("dc:contributor").text().as_string();
meta_out.summary = metadata.child("dc:description").text().as_string();
pugi::xml_node metadata = childByLocalName(package, "metadata");
meta_out.name = childByLocalName(metadata, "title").text().as_string();
meta_out.author = childByLocalName(metadata, "creator").text().as_string();
meta_out.artist = childByLocalName(metadata, "contributor").text().as_string();
meta_out.summary = childByLocalName(metadata, "description").text().as_string();
std::unordered_map<std::string, std::string> id_to_href;
std::unordered_map<std::string, std::string> id_to_media_type;
std::unordered_set<std::string> image_paths;
std::string property_cover_id;
std::string cover_id;
for (pugi::xml_node meta : metadata.children("meta"))
for (pugi::xml_node meta : metadata.children())
{
if (!hasLocalName(meta, "meta"))
continue;
if (std::string(meta.attribute("name").value()) == "cover")
{
cover_id = meta.attribute("content").value();
@@ -211,40 +335,76 @@ void parse_opf_from_folder(const std::string &base_dir,
}
}
auto manifest = opf_doc.child("package").child("manifest");
for (pugi::xml_node item : manifest.children("item"))
pugi::xml_node manifest = childByLocalName(package, "manifest");
if (!manifest)
throw std::runtime_error("EPUB package document is missing manifest");
for (pugi::xml_node item : manifest.children())
{
if (!hasLocalName(item, "item"))
continue;
std::string id = item.attribute("id").value();
std::string href = item.attribute("href").value();
std::string media_type = item.attribute("media-type").value();
std::string properties = item.attribute("properties").value();
id_to_href[id] = href;
id_to_media_type[id] = media_type;
if (media_type == "text/css")
{
meta_out.cssPaths.push_back(join(opf_dir, href));
}
else if (media_type == "image/jpeg" || media_type == "image/png" || media_type == "image/jpg")
else if (media_type.rfind("image/", 0) == 0)
{
meta_out.imagePaths.push_back(join(opf_dir, href));
std::string image_path = join(opf_dir, href);
meta_out.imagePaths.push_back(image_path);
image_paths.insert(image_path);
if (property_cover_id.empty() && hasProperty(properties, "cover-image"))
{
property_cover_id = id;
}
}
}
if (!cover_id.empty() && id_to_href.count(cover_id))
if (!cover_id.empty() && id_to_href.count(cover_id) &&
id_to_media_type[cover_id].rfind("image/", 0) == 0)
{
meta_out.cover = join(opf_dir, id_to_href[cover_id]);
}
else if (!property_cover_id.empty())
{
meta_out.cover = join(opf_dir, id_to_href[property_cover_id]);
}
else if (id_to_href.count("cover-image") &&
id_to_media_type["cover-image"].rfind("image/", 0) == 0)
{
meta_out.cover = join(opf_dir, id_to_href["cover-image"]);
}
else if (!cover_id.empty() && id_to_href.count(cover_id))
{
std::string cover_document_path = join(opf_dir, id_to_href[cover_id]);
meta_out.cover = findCoverImagePath(cover_document_path, image_paths);
}
pugi::xml_node spine = childByLocalName(package, "spine");
if (!spine)
throw std::runtime_error("EPUB package document is missing spine");
auto spine = opf_doc.child("package").child("spine");
std::string prev_name = "";
int part = 2;
for (pugi::xml_node itemref : spine.children("itemref"))
for (pugi::xml_node itemref : spine.children())
{
if (!hasLocalName(itemref, "itemref"))
continue;
std::string idref = itemref.attribute("idref").value();
if (id_to_href.count(idref))
{
std::string chapter_href = id_to_href[idref];
Chapter chapter;
chapter.path = join(opf_dir, chapter_href);
normalizeEpubChapter(chapter.path);
if (path_to_label.count(chapter.path))
{
@@ -254,7 +414,7 @@ void parse_opf_from_folder(const std::string &base_dir,
{
if (prev_name.empty())
{
int start_pos = chapter_href.find_last_of("/");
size_t start_pos = chapter_href.find_last_of("/");
if (start_pos == std::string::npos)
{
start_pos = 0;
@@ -280,8 +440,13 @@ void parse_opf_from_folder(const std::string &base_dir,
meta_out.chapters.push_back(chapter);
}
}
if (meta_out.chapters.empty())
throw std::runtime_error("EPUB spine contains no readable chapters");
}
} // namespace
EpubMetadata parseEpub(const std::string epub_path)
{
std::string container_path = join(epub_path, "META-INF/container.xml");
@@ -290,11 +455,12 @@ EpubMetadata parseEpub(const std::string epub_path)
if (!container_doc.load_file(container_path.c_str()))
throw std::runtime_error("Failed to load container.xml");
std::string opf_path = container_doc.child("container")
.child("rootfiles")
.child("rootfile")
.attribute("full-path")
.value();
pugi::xml_node container = childByLocalName(container_doc, "container");
pugi::xml_node rootfiles = childByLocalName(container, "rootfiles");
pugi::xml_node rootfile = childByLocalName(rootfiles, "rootfile");
std::string opf_path = rootfile.attribute("full-path").value();
if (opf_path.empty())
throw std::runtime_error("container.xml is missing a package path");
EpubMetadata metadata;
parse_opf_from_folder(epub_path, opf_path, metadata);
@@ -0,0 +1,131 @@
#include "../import/EpubParser.hpp"
#include <algorithm>
#include <cassert>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
namespace {
void writeFile(const std::filesystem::path& path, const std::string& content) {
std::filesystem::create_directories(path.parent_path());
std::ofstream file(path);
file << content;
}
std::string readFile(const std::filesystem::path& path) {
std::ifstream file(path);
return {std::istreambuf_iterator<char>(file),
std::istreambuf_iterator<char>()};
}
bool containsPath(const std::vector<std::string>& paths,
const std::filesystem::path& expectedPath) {
return std::find(paths.begin(), paths.end(), expectedPath.string()) !=
paths.end();
}
} // namespace
int main(int argc, char** argv) {
assert(argc == 2);
const std::filesystem::path fixtureDirectory = argv[1];
std::filesystem::remove_all(fixtureDirectory);
writeFile(
fixtureDirectory / "META-INF/container.xml",
R"xml(<?xml version="1.0"?>
<ocf:container xmlns:ocf="urn:oasis:names:tc:opendocument:xmlns:container">
<ocf:rootfiles>
<ocf:rootfile full-path="OEBPS/content.opf"/>
</ocf:rootfiles>
</ocf:container>)xml");
writeFile(
fixtureDirectory / "OEBPS/content.opf",
R"xml(<?xml version="1.0"?>
<opf:package xmlns:opf="http://www.idpf.org/2007/opf"
xmlns:dc="http://purl.org/dc/elements/1.1/" version="3.0">
<opf:metadata>
<dc:title>Image fixture</dc:title>
<opf:meta name="cover" content="cover-document"/>
</opf:metadata>
<opf:manifest>
<opf:item id="nav" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
<opf:item id="cover-document" href="Text/cover.xhtml" media-type="application/xhtml+xml"/>
<opf:item id="chapter" href="Text/chapter.xhtml" media-type="application/xhtml+xml"/>
<opf:item id="jpeg" href="Images/cover.jpg" media-type="image/jpeg"/>
<opf:item id="png" href="Images/illustration.png" media-type="image/png"/>
<opf:item id="webp" href="Images/illustration.webp" media-type="image/webp"/>
<opf:item id="gif" href="Images/illustration.gif" media-type="image/gif"/>
<opf:item id="svg" href="Images/illustration.svg" media-type="image/svg+xml"/>
</opf:manifest>
<opf:spine>
<opf:itemref idref="cover-document"/>
<opf:itemref idref="chapter"/>
</opf:spine>
</opf:package>)xml");
writeFile(
fixtureDirectory / "OEBPS/toc.ncx",
R"xml(<?xml version="1.0"?>
<ncx:ncx xmlns:ncx="http://www.daisy.org/z3986/2005/ncx/">
<ncx:navMap>
<ncx:navPoint>
<ncx:navLabel><ncx:text>Chapter</ncx:text></ncx:navLabel>
<ncx:content src="Text/chapter.xhtml"/>
</ncx:navPoint>
</ncx:navMap>
</ncx:ncx>)xml");
writeFile(
fixtureDirectory / "OEBPS/Text/chapter.xhtml",
R"xml(<html><body>
<svg><title>Chapter art</title><image width="1200" height="1600" href="../Images/illustration.png"/></svg>
<svg xmlns:xlink="http://www.w3.org/1999/xlink"><image xlink:href="../Images/illustration.webp"/><path d="M0 0h10v10z"/></svg>
</body></html>)xml");
writeFile(
fixtureDirectory / "OEBPS/Text/cover.xhtml",
R"xml(<html><body><svg xmlns:xlink="http://www.w3.org/1999/xlink"><image xlink:href="../Images/cover.jpg"/></svg></body></html>)xml");
const EpubMetadata metadata = parseEpub(fixtureDirectory.string());
const std::filesystem::path imageDirectory = fixtureDirectory / "OEBPS/Images";
assert(metadata.name == "Image fixture");
assert(metadata.imagePaths.size() == 5);
assert(containsPath(metadata.imagePaths, imageDirectory / "cover.jpg"));
assert(containsPath(metadata.imagePaths,
imageDirectory / "illustration.png"));
assert(containsPath(metadata.imagePaths,
imageDirectory / "illustration.webp"));
assert(containsPath(metadata.imagePaths,
imageDirectory / "illustration.gif"));
assert(containsPath(metadata.imagePaths,
imageDirectory / "illustration.svg"));
assert(metadata.cover == (imageDirectory / "cover.jpg").string());
assert(metadata.chapters.size() == 2);
assert(metadata.chapters.front().path ==
(fixtureDirectory / "OEBPS/Text/cover.xhtml").string());
assert(metadata.chapters[1].name == "Chapter");
const std::string coverChapter =
readFile(fixtureDirectory / "OEBPS/Text/cover.xhtml");
assert(coverChapter.find("<svg") == std::string::npos);
assert(coverChapter.find("<img src=\"../Images/cover.jpg\"") !=
std::string::npos);
const std::string regularChapter =
readFile(fixtureDirectory / "OEBPS/Text/chapter.xhtml");
assert(regularChapter.find(
"<img width=\"1200\" height=\"1600\" "
"src=\"../Images/illustration.png\" alt=\"Chapter art\"") !=
std::string::npos);
assert(regularChapter.find(
"<image xlink:href=\"../Images/illustration.webp\"") !=
std::string::npos);
assert(regularChapter.find("<path d=\"M0 0h10v10z\"") !=
std::string::npos);
return 0;
}
@@ -3,6 +3,10 @@ package com.margelo.nitro.nitrotts
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
@@ -29,9 +33,18 @@ internal object TtsPlaybackStore {
private var state = TtsPlaybackState.IDLE
private var generation = 0L
private var audioManager: AudioManager? = null
private var audioFocusRequest: AudioFocusRequest? = null
private var hasAudioFocus = false
private var resumeOnFocusGain = false
fun prepare(context: Context, completion: (Result<Unit>) -> Unit) {
runOnOwner {
applicationContext = context.applicationContext
if (audioManager == null) {
audioManager =
applicationContext?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
}
if (isReady && boundEngineName == settings.engineName) {
completion(Result.success(Unit))
return@runOnOwner
@@ -163,14 +176,16 @@ internal object TtsPlaybackStore {
speakCurrent()
}
/**
* Manual pause (from the user, media notification, or MediaSession controls).
* Always wins over a later focus regain — even if we're currently paused because of a
* transient focus loss (e.g. a call), calling this makes sure TTS stays paused once the
* call ends instead of auto-resuming.
*/
fun pause() {
if (state != TtsPlaybackState.PLAYING) {
return
}
generation += 1
engine?.stop()
state = TtsPlaybackState.PAUSED
emitState()
resumeOnFocusGain = false
abandonAudioFocus()
pauseEngine()
}
fun stop() {
@@ -180,10 +195,97 @@ internal object TtsPlaybackStore {
currentIndex = 0
metadata = null
state = TtsPlaybackState.IDLE
resumeOnFocusGain = false
abandonAudioFocus()
emitState()
applicationContext?.let { TtsPlaybackService.stop(it) }
}
/** Stops the engine and marks playback paused, without touching audio focus. */
private fun pauseEngine() {
if (state != TtsPlaybackState.PLAYING) {
return
}
generation += 1
engine?.stop()
state = TtsPlaybackState.PAUSED
emitState()
}
/**
* Requests playback focus so the system can tell us to pause for a call or other audio
* that genuinely needs exclusive use of the speaker. Duck-only interruptions (notification
* sounds, alerts) are left alone - see [handleAudioFocusChange].
*/
private fun requestAudioFocus(): Boolean {
if (hasAudioFocus) return true
val manager = audioManager ?: return false
val granted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val attributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(attributes)
.setOnAudioFocusChangeListener(audioFocusChangeListener, ownerHandler)
.build()
audioFocusRequest = request
manager.requestAudioFocus(request) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
} else {
@Suppress("DEPRECATION")
manager.requestAudioFocus(
audioFocusChangeListener,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN,
) == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
}
hasAudioFocus = granted
return granted
}
private fun abandonAudioFocus() {
if (!hasAudioFocus) return
val manager = audioManager
if (manager != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let { manager.abandonAudioFocusRequest(it) }
} else {
@Suppress("DEPRECATION")
manager.abandonAudioFocus(audioFocusChangeListener)
}
}
hasAudioFocus = false
}
private val audioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { focusChange ->
runOnOwner { handleAudioFocusChange(focusChange) }
}
private fun handleAudioFocusChange(focusChange: Int) {
when (focusChange) {
AudioManager.AUDIOFOCUS_LOSS -> {
// Another app now owns audio focus outright (e.g. a call was answered).
hasAudioFocus = false
resumeOnFocusGain = false
pauseEngine()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
// Something needs exclusive use of the speaker for a short while
// Pause and remember to resume once it's done.
resumeOnFocusGain = state == TtsPlaybackState.PLAYING
pauseEngine()
}
AudioManager.AUDIOFOCUS_GAIN -> {
hasAudioFocus = true
if (resumeOnFocusGain) {
resumeOnFocusGain = false
play()
}
}
}
}
fun skipPrevious() {
check(paragraphs.isNotEmpty()) { "Load a paragraph queue before seeking." }
currentIndex = (currentIndex - 1).coerceAtLeast(0)
@@ -255,6 +357,11 @@ internal object TtsPlaybackStore {
return
}
if (!requestAudioFocus()) {
fail("Couldn't get audio focus - another app or call may be using audio.")
return
}
val activeEngine = checkNotNull(engine)
val paragraph = paragraphs[currentIndex]
generation += 1
@@ -276,6 +383,7 @@ internal object TtsPlaybackStore {
}
state = TtsPlaybackState.PLAYING
resumeOnFocusGain = false
emitProgress()
emitState()
}
@@ -392,4 +500,4 @@ internal object TtsPlaybackStore {
}
}
}
}
}
+17 -13
View File
@@ -1,6 +1,6 @@
{
"name": "lnreader",
"version": "2.1.2",
"version": "2.1.4",
"private": true,
"scripts": {
"dev:android:release": "pnpm run generate:env:release && npx expo run:android --variant release",
@@ -8,7 +8,7 @@
"dev:ios": "npx expo run:ios",
"dev:start": "npx expo start",
"dev:clean-start": "npx expo start --clear",
"build:release:android": "pnpm run generate:env:release && pnpm exec expo prebuild --platform android --clean --no-install && mkdir -p android/app/build/intermediates/sourcemaps/react/release && cd android && NODE_ENV=production ./gradlew :app:assembleRelease --build-cache --parallel",
"build:release:android": "pnpm run generate:env:release && pnpm exec expo prebuild --platform android --clean --no-install && mkdir -p android/app/build/intermediates/sourcemaps/react/release && cd android && NODE_ENV=production ./gradlew :app:assembleRelease --build-cache --parallel -PreleaseAbiSplits=true",
"build:open-apk": "open ./android/app/build/outputs/apk/release/",
"generate:env:debug": "node scripts/generate-env-file.cjs --build-type Debug",
"generate:env:release": "node scripts/generate-env-file.cjs --build-type Release",
@@ -36,23 +36,19 @@
"nix:emulator": "nix develop .#emulator --extra-experimental-features nix-command --extra-experimental-features flakes"
},
"dependencies": {
"@expo/dom-webview": "^57.0.1",
"@gorhom/bottom-sheet": "^5.2.14",
"@legendapp/list": "^3.3.3",
"@noble/ciphers": "^2.2.0",
"@op-engineering/op-sqlite": "^15.2.14",
"@pchmn/expo-material3-theme": "^1.4.0",
"@preeternal/react-native-cookie-manager": "^6.3.3",
"@react-native-documents/picker": "^12.0.1",
"@react-native-google-signin/google-signin": "^16.1.2",
"@react-native-vector-icons/common": "^13.0.1",
"@react-native-vector-icons/material-design-icons": "^13.1.2",
"@react-navigation/bottom-tabs": "^7.18.11",
"@react-navigation/native": "^7.3.11",
"@react-navigation/native-stack": "^7.18.3",
"@react-navigation/stack": "^7.10.14",
"@shopify/flash-list": "^2.0.2",
"babel-plugin-inline-import": "^3.0.0",
"@rozenite/react-navigation-plugin": "^2.0.0",
"cheerio": "1.0.0-rc.12",
"color": "^5.0.3",
"dayjs": "^1.11.21",
@@ -76,28 +72,30 @@
"htmlparser2": "^12.0.0",
"i18n-js": "^4.5.3",
"lodash-es": "^4.18.1",
"lottie-react-native": "^7.3.8",
"prettier": "2.8.8",
"protobufjs": "^8.7.1",
"react": "19.2.3",
"react-native": "0.86.0",
"react-native": "^0.86.0",
"react-native-device-info": "^15.0.2",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-drawer-layout": "^4.2.8",
"react-native-edge-to-edge": "^1.8.1",
"react-native-error-boundary": "^3.1.0",
"react-native-gesture-handler": "^2.32.0",
"react-native-keyboard-controller": "^1.22.2",
"react-native-mmkv": "^4.3.2",
"react-native-pager-view": "^8.0.2",
"react-native-paper": "^5.15.3",
"react-native-reanimated": "^4.5.0",
"react-native-saf-x": "^2.2.3",
"react-native-safe-area-context": "^5.7.0",
"react-native-screens": "^4.25.2",
"react-native-shimmer-placeholder": "^2.0.9",
"react-native-svg": "15.15.4",
"react-native-tab-view": "^4.3.2",
"react-native-url-polyfill": "^3.0.0",
"react-native-webview": "^13.16.1",
"react-native-worklets": "^0.10.0",
"react-syntax-highlighter": "^16.1.1",
"sanitize-html": "^2.17.6",
"urlencode": "^2.0.0",
"zustand": "^5.0.14"
@@ -117,9 +115,11 @@
"@types/jest": "^29.5.14",
"@types/lodash-es": "^4.17.12",
"@types/react": "~19.2.17",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/sanitize-html": "^2.16.1",
"@typescript-eslint/eslint-plugin": "^8.64.0",
"@typescript-eslint/parser": "^8.64.0",
"babel-plugin-inline-import": "^3.0.0",
"babel-plugin-module-resolver": "^5.0.3",
"babel-plugin-react-compiler": "^1.0.0",
"better-sqlite3": "^12.11.1",
@@ -134,23 +134,27 @@
"jest-expo": "^57.0.2",
"lint-staged": "^12.5.0",
"nitrogen": "^0.36.1",
"prettier": "2.8.8",
"react-native-nitro-modules": "^0.36.0",
"react-test-renderer": "19.2.3",
"typescript": "~6.0.3"
},
"expo": {
"autolinking": {
"exclude": [
"@expo/dom-webview"
]
},
"doctor": {
"reactNativeDirectoryCheck": {
"exclude": [
"native-file",
"native-crash-logs",
"nitro-tts",
"native-volume-button-listener",
"native-zip-archive",
"nitro-epub",
"native-background-tasks",
"@react-native-vector-icons/material-design-icons",
"react-native-saf-x",
"lottie-ios"
]
}
@@ -168,5 +172,5 @@
"engines": {
"node": ">=22.11.0"
},
"packageManager": "pnpm@11.15.0"
"packageManager": "pnpm@11.20.0"
}
@@ -3,7 +3,18 @@ def releaseAbiSplitsEnabled = providers
.map { value -> value.toBoolean() }
.getOrElse(false)
react {
hermesFlags = ["-O", "-output-source-map"]
}
android {
defaultConfig {
// Library resources (Material Components, RN, play-services) only for
// languages the app itself ships (src/i18n/languages/*); other locales
// fall back to English, matching the app's own JS fallback.
resConfigs "af", "ar", "as", "ca", "cs", "da", "de", "el", "en", "es", "fi", "fr", "he", "hi", "hu", "id", "it", "ja", "ko", "nl", "no", "or", "pl", "pt", "ro", "ru", "sq", "sr", "sv", "tr", "uk", "vi", "zh"
}
splits {
abi {
enable releaseAbiSplitsEnabled
+53
View File
@@ -1,4 +1,8 @@
const {
AndroidConfig,
withAndroidColors,
withAndroidColorsNight,
withAndroidStyles,
withAppBuildGradle,
withDangerousMod,
} = require('@expo/config-plugins');
@@ -35,6 +39,53 @@ const DRAWABLE_RESOURCE_NAMES = [
];
const ANDROID_DENSITIES = ['mdpi', 'hdpi', 'xhdpi', 'xxhdpi', 'xxxhdpi'];
/**
* `AppTheme` inherits `android:windowBackground` from
* `Theme.AppCompat.DayNight.NoActionBar`, which resolves to a near-white colour
* whenever the system is in light mode. The window is visible for the frames
* between the splash screen going away and React drawing its first frame, so
* the app opens with a white flash for anyone using a dark theme on a light
* system. These are the backgrounds of the default light and dark themes.
*/
const WINDOW_BACKGROUND_RESOURCE = 'lnreaderWindowBackground';
const WINDOW_BACKGROUND_LIGHT = '#FEFBFF';
const WINDOW_BACKGROUND_DARK = '#1B1B1F';
const withWindowBackgroundColors = config => {
config = withAndroidColors(config, config => {
config.modResults = AndroidConfig.Colors.assignColorValue(
config.modResults,
{ name: WINDOW_BACKGROUND_RESOURCE, value: WINDOW_BACKGROUND_LIGHT },
);
return config;
});
return withAndroidColorsNight(config, config => {
config.modResults = AndroidConfig.Colors.assignColorValue(
config.modResults,
{ name: WINDOW_BACKGROUND_RESOURCE, value: WINDOW_BACKGROUND_DARK },
);
return config;
});
};
const withWindowBackgroundStyle = config =>
withAndroidStyles(config, config => {
config.modResults = AndroidConfig.Styles.assignStylesValue(
config.modResults,
{
add: true,
parent: AndroidConfig.Styles.getAppThemeGroup(),
name: 'android:windowBackground',
value: `@color/${WINDOW_BACKGROUND_RESOURCE}`,
},
);
return config;
});
const withBuildGradleCustomizations = config =>
withAppBuildGradle(config, config => {
const { contents } = config.modResults;
@@ -133,5 +184,7 @@ const withAndroidVariantAssets = config =>
module.exports = config => {
config = withBuildGradleCustomizations(config);
config = withWindowBackgroundColors(config);
config = withWindowBackgroundStyle(config);
return withAndroidVariantAssets(config);
};
+543 -198
View File
File diff suppressed because it is too large Load Diff
@@ -1,10 +1,17 @@
import React from 'react';
import { StyleSheet, View, Text, StatusBar } from 'react-native';
import React, { useMemo, useState } from 'react';
import { StyleSheet, View, Text, StatusBar, ScrollView } from 'react-native';
import ErrorBoundary from 'react-native-error-boundary';
import * as Clipboard from 'expo-clipboard';
import DeviceInfo from 'react-native-device-info';
import { BUILD_TYPE, GIT_HASH } from '@env';
import { version } from '../../../package.json';
import { getString } from '@i18n/translations';
import { getErrorChainMessages } from '@utils/error';
import { showToast } from '@utils/showToast';
import { Button, List } from '@components';
import { useTheme } from '@hooks/persisted';
import { SafeAreaView } from 'react-native-safe-area-context';
import { restartApplication, shareCrashLogs } from '@services/crashLogs';
interface ErrorFallbackProps {
error: Error;
@@ -16,6 +23,67 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({
resetError,
}) => {
const theme = useTheme();
const [isSharing, setIsSharing] = useState(false);
const fallbackGetString = (
key: Parameters<typeof getString>[0],
fallback: string,
options?: Parameters<typeof getString>[1],
) => {
try {
return getString(key, options);
} catch {
return fallback;
}
};
const chainMessages = useMemo(() => getErrorChainMessages(error), [error]);
const versionDetails = [
`${fallbackGetString(
'aboutScreen.version',
'Version',
)}: ${version} (${DeviceInfo.getBuildNumber()})`,
BUILD_TYPE || 'Custom build',
GIT_HASH,
]
.filter(Boolean)
.join(' · ');
const copyStackTrace = async () => {
try {
const message = chainMessages.join('\n\nCaused by: ');
await Clipboard.setStringAsync(`${message}\n\n${error.stack}`);
return true;
} catch {
return false;
}
};
const handleShareCrashLogs = async () => {
setIsSharing(true);
try {
await shareCrashLogs(error);
} catch {
const copiedStackTrace = await copyStackTrace();
showToast(
copiedStackTrace
? fallbackGetString(
'errorBoundary.shareCrashLogsFailed',
'Could not share crash logs. The stack trace was copied instead.',
)
: fallbackGetString(
'errorBoundary.shareCrashLogsFailedWithoutCopy',
'Could not share crash logs or copy the stack trace.',
),
);
} finally {
setIsSharing(false);
}
};
const handleRestart = () => {
restartApplication(resetError).catch(resetError);
};
return (
<SafeAreaView
@@ -24,33 +92,57 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({
<StatusBar translucent={true} backgroundColor="transparent" />
<View style={styles.errorInfoCtn}>
<Text style={[styles.errorTitle, { color: theme.onSurface }]}>
An Unexpected Error Ocurred
{fallbackGetString(
'errorBoundary.title',
'An Unexpected Error Occurred',
)}
</Text>
<Text style={[styles.errorDesc, { color: theme.onSurface }]}>
The application ran into an unexpected error. We suggest you
screenshot this message and then share it in our support channel on
Discord.
{fallbackGetString(
'errorBoundary.description',
'The application ran into an unexpected error. Please share the crash logs in our Discord support channel.',
)}
</Text>
<Text
style={[styles.versionDetails, { color: theme.onSurfaceVariant }]}
>
{versionDetails}
</Text>
<ScrollView
style={[
styles.errorCtn,
{
backgroundColor: theme.surfaceVariant,
color: theme.onSurfaceVariant,
},
]}
numberOfLines={20}
contentContainerStyle={styles.errorContent}
>
{`${error.message}\n\n${error.stack}`}
</Text>
<Text style={[styles.errorText, { color: theme.onSurfaceVariant }]}>
{`${chainMessages.join('\n\nCaused by: ')}\n\n${error.stack}`}
</Text>
</ScrollView>
</View>
<List.Divider theme={theme} />
<Button
onPress={resetError}
title={'Restart the application'}
style={styles.buttonCtn}
mode="contained"
/>
<View style={styles.actionsCtn}>
<Button
disabled={isSharing}
loading={isSharing}
onPress={handleShareCrashLogs}
title={fallbackGetString(
'errorBoundary.shareCrashLogs',
'Share crash logs',
)}
mode="outlined"
/>
<Button
onPress={handleRestart}
title={fallbackGetString(
'errorBoundary.restart',
'Restart the application',
)}
mode="contained"
/>
</View>
</SafeAreaView>
);
};
@@ -68,31 +160,42 @@ const AppErrorBoundary: React.FC<AppErrorBoundaryProps> = ({ children }) => {
export default AppErrorBoundary;
const styles = StyleSheet.create({
buttonCtn: {
margin: 16,
marginBottom: 32,
actionsCtn: {
gap: 16,
paddingHorizontal: 16,
paddingVertical: 20,
},
errorCtn: {
flex: 1,
borderRadius: 8,
lineHeight: 20,
marginVertical: 16,
marginBottom: 16,
},
errorContent: {
paddingHorizontal: 8,
paddingVertical: 16,
},
errorText: {
lineHeight: 20,
},
errorDesc: {
lineHeight: 20,
marginVertical: 8,
marginTop: 8,
},
errorInfoCtn: {
flex: 1,
justifyContent: 'center',
padding: 16,
paddingTop: 32,
},
errorTitle: {
fontSize: 20,
marginBottom: 8,
textAlign: 'center',
},
versionDetails: {
lineHeight: 24,
marginVertical: 16,
textAlign: 'center',
},
mainCtn: {
flex: 1,
},
@@ -0,0 +1,35 @@
import { PropsWithChildren, useEffect, useMemo, useState } from 'react';
import * as SplashScreen from 'expo-splash-screen';
import { getPendingCrash } from '@services/crashLogs';
import { ErrorFallback } from './AppErrorBoundary';
const CrashRecoveryGate = ({ children }: PropsWithChildren) => {
const [pendingCrash, setPendingCrash] = useState<string | null>();
useEffect(() => {
getPendingCrash()
.then(crash => {
setPendingCrash(crash);
if (crash) {
SplashScreen.hideAsync();
}
})
.catch(() => setPendingCrash(null));
}, []);
const crashError = useMemo(() => {
if (!pendingCrash) return null;
const error = new Error('LNReader stopped unexpectedly on its last run.');
error.stack = pendingCrash;
return error;
}, [pendingCrash]);
if (pendingCrash === undefined) return null;
if (crashError) {
return <ErrorFallback error={crashError} resetError={() => null} />;
}
return children;
};
export default CrashRecoveryGate;
@@ -0,0 +1,94 @@
import type { ReactNode } from 'react';
import { fireEvent, render, screen, waitFor } from '@test-utils';
import { ErrorFallback } from '../AppErrorBoundary';
import { version } from '../../../../package.json';
const mockShareCrashLogs = jest.fn();
const mockRestartApplication = jest.fn();
const mockTheme = {
background: '#ffffff',
onSurface: '#000000',
onSurfaceVariant: '#333333',
surfaceVariant: '#eeeeee',
outlineVariant: '#cccccc',
primary: '#0061a4',
};
jest.mock('@hooks/persisted/useTheme', () => ({
ThemeProvider: ({ children }: { children: ReactNode }) => children,
useTheme: () => mockTheme,
}));
jest.mock('@hooks/persisted', () => ({
useTheme: () => mockTheme,
}));
jest.mock('react-native-device-info', () => ({
__esModule: true,
default: { getBuildNumber: () => '42' },
}));
jest.mock('@env', () => ({ BUILD_TYPE: 'Debug', GIT_HASH: 'abc123' }));
jest.mock('@screens/novel/NovelContext', () => ({
NovelContextProvider: ({ children }: { children: ReactNode }) => children,
}));
jest.mock('react-native-safe-area-context', () => {
const ReactModule = require('react');
const { View } = jest.requireActual('react-native');
const frame = { height: 800, width: 400, x: 0, y: 0 };
const insets = { bottom: 0, left: 0, right: 0, top: 0 };
return {
SafeAreaFrameContext: ReactModule.createContext(frame),
SafeAreaInsetsContext: ReactModule.createContext(insets),
SafeAreaProvider: ({ children }: { children: ReactNode }) => children,
SafeAreaView: ({ children, ...props }: { children: ReactNode }) =>
ReactModule.createElement(View, props, children),
initialWindowMetrics: { frame, insets },
useSafeAreaFrame: () => frame,
useSafeAreaInsets: () => insets,
};
});
jest.mock('@services/crashLogs', () => ({
shareCrashLogs: (error: Error) => mockShareCrashLogs(error),
restartApplication: (fallback: () => void) =>
mockRestartApplication(fallback),
}));
describe('ErrorFallback', () => {
beforeEach(() => {
mockShareCrashLogs.mockResolvedValue(undefined);
mockRestartApplication.mockResolvedValue(undefined);
});
it('shares a diagnostic report for the displayed error', async () => {
const error = new Error('boom');
render(<ErrorFallback error={error} resetError={jest.fn()} />);
fireEvent.press(screen.getByText('Share crash logs'));
await waitFor(() => expect(mockShareCrashLogs).toHaveBeenCalledWith(error));
});
it('shows version and build details on the error screen', () => {
render(<ErrorFallback error={new Error('boom')} resetError={jest.fn()} />);
expect(
screen.getByText(`Version: ${version} (42) · Debug · abc123`),
).toBeTruthy();
});
it('uses the native restart flow', async () => {
const resetError = jest.fn();
render(<ErrorFallback error={new Error('boom')} resetError={resetError} />);
fireEvent.press(screen.getByText('Restart the application'));
await waitFor(() =>
expect(mockRestartApplication).toHaveBeenCalledWith(resetError),
);
});
});
@@ -0,0 +1,39 @@
import { useCallback, useState } from 'react';
import AppUpdateDialog from './AppUpdateDialog';
import { useAppUpdateChecker } from './useAppUpdateChecker';
const AppUpdateChecker = () => {
const { ignoreVersion, isNewVersion, latestRelease } = useAppUpdateChecker();
const [dismissedVersion, setDismissedVersion] = useState<string>();
const versionTag = latestRelease?.tag_name;
const dismissUpdate = useCallback(() => {
setDismissedVersion(versionTag);
}, [versionTag]);
const ignoreUpdate = useCallback(() => {
if (versionTag) {
ignoreVersion(versionTag);
}
}, [ignoreVersion, versionTag]);
if (
!isNewVersion ||
!latestRelease ||
dismissedVersion === latestRelease.tag_name
) {
return null;
}
return (
<AppUpdateDialog
release={latestRelease}
onDismiss={dismissUpdate}
onIgnore={ignoreUpdate}
/>
);
};
export default AppUpdateChecker;
@@ -0,0 +1,66 @@
import { StyleSheet, useWindowDimensions } from 'react-native';
import * as Linking from 'expo-linking';
import { ScrollView } from 'react-native-gesture-handler';
import { getString } from '@i18n/translations';
import { Dialog } from '../Dialog';
import type { AppRelease } from './useAppUpdateChecker';
interface AppUpdateDialogProps {
release: AppRelease;
onDismiss: () => void;
onIgnore: () => void;
}
const AppUpdateDialog = ({
release,
onDismiss,
onIgnore,
}: AppUpdateDialogProps) => {
const maxContentHeight = useWindowDimensions().height / 2;
const installUpdate = () => {
if (release.downloadUrl) {
void Linking.openURL(release.downloadUrl);
}
};
return (
<Dialog.Root visible onDismiss={onDismiss}>
<Dialog.Title>
{`${getString('common.newUpdateAvailable')} ${release.tag_name}`}
</Dialog.Title>
<Dialog.ScrollArea>
<ScrollView
contentContainerStyle={styles.scrollContent}
style={{ maxHeight: maxContentHeight }}
testID="app-update-release-notes"
>
<Dialog.Description>{release.body.trim()}</Dialog.Description>
</ScrollView>
</Dialog.ScrollArea>
<Dialog.Actions>
<Dialog.Action title={getString('common.later')} onPress={onDismiss} />
<Dialog.Action
title={getString('common.skipVersion')}
onPress={onIgnore}
/>
<Dialog.Action
title={getString('common.install')}
disabled={!release.downloadUrl}
onPress={installUpdate}
/>
</Dialog.Actions>
</Dialog.Root>
);
};
export default AppUpdateDialog;
const styles = StyleSheet.create({
scrollContent: {
paddingHorizontal: 24,
paddingVertical: 16,
},
});
@@ -0,0 +1,72 @@
import { act, render, screen } from '@testing-library/react-native';
import AppUpdateChecker from '../AppUpdateChecker';
import { useAppUpdateChecker } from '../useAppUpdateChecker';
jest.mock('../useAppUpdateChecker', () => ({
useAppUpdateChecker: jest.fn(),
}));
jest.mock('../AppUpdateDialog', () => {
const React = jest.requireActual<typeof import('react')>('react');
return function MockAppUpdateDialog({
onDismiss,
onIgnore,
}: {
onDismiss: () => void;
onIgnore: () => void;
}) {
return React.createElement('View', {
onDismiss,
onIgnore,
testID: 'app-update-dialog',
});
};
});
const mockIgnoreVersion = jest.fn();
const mockAppRelease = {
tag_name: 'v9.0.0',
body: 'Release notes',
downloadUrl: 'https://example.com/lnreader.apk',
};
describe('AppUpdateChecker', () => {
beforeEach(() => {
jest.mocked(useAppUpdateChecker).mockReturnValue({
ignoreVersion: mockIgnoreVersion,
isNewVersion: true,
latestRelease: mockAppRelease,
});
});
it('renders nothing when no update is available', () => {
jest.mocked(useAppUpdateChecker).mockReturnValue({
ignoreVersion: mockIgnoreVersion,
isNewVersion: false,
latestRelease: undefined,
});
render(<AppUpdateChecker />);
expect(screen.queryByTestId('app-update-dialog')).toBeNull();
});
it('dismisses the current release for the session', () => {
render(<AppUpdateChecker />);
act(() => screen.getByTestId('app-update-dialog').props.onDismiss());
expect(screen.queryByTestId('app-update-dialog')).toBeNull();
expect(mockIgnoreVersion).not.toHaveBeenCalled();
});
it('persists the release tag when ignored', () => {
render(<AppUpdateChecker />);
act(() => screen.getByTestId('app-update-dialog').props.onIgnore());
expect(mockIgnoreVersion).toHaveBeenCalledWith(mockAppRelease.tag_name);
});
});
@@ -0,0 +1,81 @@
import { fireEvent, render, screen } from '@testing-library/react-native';
import * as Linking from 'expo-linking';
import AppUpdateDialog from '../AppUpdateDialog';
jest.mock('@i18n/translations', () => ({
getString: (key: string) =>
({
'common.install': 'Install',
'common.later': 'Later',
'common.newUpdateAvailable': 'New update available',
'common.skipVersion': 'Skip version',
}[key] ?? key),
}));
jest.mock('@hooks/persisted', () => ({
useTheme: () => ({
onSurface: '#1d1b20',
onSurfaceVariant: '#49454f',
outlineVariant: '#cac4d0',
primary: '#6750a4',
scrim: '#000000',
surface: '#fffbfe',
surfaceContainerHigh: '#ece6f0',
}),
}));
jest.mock('expo-linking', () => ({
openURL: jest.fn(),
}));
const release = {
tag_name: 'v9.0.0',
body: '## Changes\n- First\n- Second\n',
downloadUrl: 'https://example.com/lnreader.apk',
};
describe('AppUpdateDialog', () => {
it('preserves release-note spacing within a content-sized scroll area', () => {
render(
<AppUpdateDialog
release={release}
onDismiss={jest.fn()}
onIgnore={jest.fn()}
/>,
);
expect(screen.getByText('## Changes\n- First\n- Second')).toBeTruthy();
expect(screen.getByTestId('app-update-release-notes').props.style).toEqual({
maxHeight: expect.any(Number),
});
expect(
screen.getByTestId('app-update-release-notes').props
.contentContainerStyle,
).toEqual({
paddingHorizontal: 24,
paddingVertical: 16,
});
});
it('exposes dismiss, skip, and install actions', () => {
const onDismiss = jest.fn();
const onIgnore = jest.fn();
render(
<AppUpdateDialog
release={release}
onDismiss={onDismiss}
onIgnore={onIgnore}
/>,
);
fireEvent.press(screen.getByText('Later'));
fireEvent.press(screen.getByText('Skip version'));
fireEvent.press(screen.getByText('Install'));
expect(onDismiss).toHaveBeenCalledTimes(1);
expect(onIgnore).toHaveBeenCalledTimes(1);
expect(Linking.openURL).toHaveBeenCalledWith(release.downloadUrl);
});
});
@@ -0,0 +1,91 @@
import { act, renderHook, waitFor } from '@testing-library/react-native';
import { MMKVStorage } from '@utils/mmkv/mmkv';
import { useAppUpdateChecker } from '../useAppUpdateChecker';
jest.mock('@utils/mmkv/mmkv', () => ({
MMKVStorage: {
getNumber: jest.fn(),
getString: jest.fn(),
set: jest.fn(),
},
}));
jest.mock('react-native-device-info', () => ({
__esModule: true,
default: {
supportedAbis: jest.fn().mockResolvedValue(['arm64-v8a']),
},
}));
const mockRelease = {
tag_name: 'v9.0.0',
body: 'Release notes',
assets: [
{
name: 'LNReader-v9.0.0-arm64-v8a.apk',
browser_download_url: 'https://example.com/lnreader.apk',
},
],
};
const mockFetchRelease = () =>
jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: jest.fn().mockResolvedValue(mockRelease),
} as unknown as Response);
describe('useAppUpdateChecker', () => {
beforeEach(() => {
jest.mocked(MMKVStorage.getNumber).mockReturnValue(undefined);
jest.mocked(MMKVStorage.getString).mockReturnValue(undefined);
});
it('reports a newer release that has not been ignored', async () => {
mockFetchRelease();
const { result } = renderHook(useAppUpdateChecker);
await waitFor(() => expect(result.current.isNewVersion).toBe(true));
expect(result.current.latestRelease).toEqual({
tag_name: mockRelease.tag_name,
body: mockRelease.body,
downloadUrl: mockRelease.assets[0].browser_download_url,
});
});
it('persists and immediately suppresses the ignored release', async () => {
mockFetchRelease();
const { result } = renderHook(useAppUpdateChecker);
await waitFor(() => expect(result.current.isNewVersion).toBe(true));
act(() => result.current.ignoreVersion(mockRelease.tag_name));
expect(MMKVStorage.set).toHaveBeenCalledWith(
'IGNORED_UPDATE_VERSION',
mockRelease.tag_name,
);
expect(result.current.isNewVersion).toBe(false);
});
it('does not report a release that was ignored previously', async () => {
jest.mocked(MMKVStorage.getString).mockReturnValue(mockRelease.tag_name);
mockFetchRelease();
const { result } = renderHook(useAppUpdateChecker);
await waitFor(() => expect(result.current.latestRelease).toBeDefined());
expect(result.current.isNewVersion).toBe(false);
});
it('reports a newer release after an older version was ignored', async () => {
jest.mocked(MMKVStorage.getString).mockReturnValue('v8.0.0');
mockFetchRelease();
const { result } = renderHook(useAppUpdateChecker);
await waitFor(() => expect(result.current.isNewVersion).toBe(true));
});
});
+1
View File
@@ -0,0 +1 @@
export { default } from './AppUpdateChecker';
@@ -1,22 +1,40 @@
import { useState, useEffect, useCallback } from 'react';
import { version } from '../../../package.json';
import { newer } from '@utils/compareVersion';
import { MMKVStorage } from '@utils/mmkv/mmkv';
import { useCallback, useEffect, useState } from 'react';
import DeviceInfo from 'react-native-device-info';
interface GithubUpdate {
isNewVersion: boolean;
latestRelease: any;
}
import { newer } from '@utils/compareVersion';
import { MMKVStorage } from '@utils/mmkv/mmkv';
const LAST_UPDATE_CHECK_KEY = 'LAST_UPDATE_CHECK';
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
import { version } from '../../../package.json';
interface AppUpdate {
isNewVersion: boolean;
latestRelease?: AppRelease;
ignoreVersion: (versionTag: string) => void;
}
interface GithubReleaseAsset {
name?: string;
browser_download_url?: string;
}
interface GithubRelease {
tag_name?: string;
body?: string;
assets?: GithubReleaseAsset[];
}
export interface AppRelease {
tag_name: string;
body: string;
downloadUrl?: string;
}
const LATEST_RELEASE_URL =
'https://api.github.com/repos/LNReader/lnreader/releases/latest';
const LAST_UPDATE_CHECK_KEY = 'LAST_UPDATE_CHECK';
const IGNORED_UPDATE_VERSION_KEY = 'IGNORED_UPDATE_VERSION';
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
const getReleaseDownloadUrl = async (
assets: GithubReleaseAsset[] = [],
): Promise<string | undefined> => {
@@ -48,24 +66,31 @@ const getReleaseDownloadUrl = async (
);
};
export const useGithubUpdateChecker = (): GithubUpdate => {
const latestReleaseUrl =
'https://api.github.com/repos/LNReader/lnreader/releases/latest';
const shouldCheckForUpdate = (): boolean => {
const lastCheckTime = MMKVStorage.getNumber(LAST_UPDATE_CHECK_KEY);
if (!lastCheckTime) {
return true;
}
return Date.now() - lastCheckTime >= ONE_DAY_MS;
};
const isNewerVersion = (versionTag: string): boolean => {
const normalizedVersion = versionTag.replace(/[^\d.]/, '');
return newer(normalizedVersion, `${version}`);
};
export const useAppUpdateChecker = (): AppUpdate => {
const [checking, setChecking] = useState(true);
const [latestRelease, setLatestRelease] = useState<any>();
const [latestRelease, setLatestRelease] = useState<AppRelease>();
const [ignoredVersion, setIgnoredVersion] = useState(() =>
MMKVStorage.getString(IGNORED_UPDATE_VERSION_KEY),
);
const shouldCheckForUpdate = (): boolean => {
const lastCheckTime = MMKVStorage.getNumber(LAST_UPDATE_CHECK_KEY);
if (!lastCheckTime) {
return true;
}
const now = Date.now();
const timeSinceLastCheck = now - lastCheckTime;
return timeSinceLastCheck >= ONE_DAY_MS;
};
const ignoreVersion = useCallback((versionTag: string) => {
MMKVStorage.set(IGNORED_UPDATE_VERSION_KEY, versionTag);
setIgnoredVersion(versionTag);
}, []);
const checkForRelease = useCallback(async () => {
if (!shouldCheckForUpdate()) {
@@ -74,60 +99,49 @@ export const useGithubUpdateChecker = (): GithubUpdate => {
}
try {
const res = await fetch(latestReleaseUrl);
const response = await fetch(LATEST_RELEASE_URL);
if (!res.ok) {
if (!response.ok) {
setChecking(false);
return;
}
const data = await res.json();
const data = (await response.json()) as GithubRelease;
if (!data || !data.tag_name) {
if (!data.tag_name) {
setChecking(false);
return;
}
const downloadUrl = await getReleaseDownloadUrl(data.assets);
const release = {
tag_name: data.tag_name,
body: data.body,
downloadUrl,
};
MMKVStorage.set(LAST_UPDATE_CHECK_KEY, Date.now());
setLatestRelease(release);
setLatestRelease({
tag_name: data.tag_name,
body: data.body ?? '',
downloadUrl,
});
setChecking(false);
} catch {
// Silently fail in offline mode or on network errors
// Silently fail in offline mode or on network errors.
setChecking(false);
}
}, []);
const isNewVersion = (versionTag: string) => {
const currentVersion = `${version}`;
const regex = /[^\d.]/;
const newVersion = versionTag.replace(regex, '');
return newer(newVersion, currentVersion);
};
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
checkForRelease();
void checkForRelease();
}, [checkForRelease]);
if (!checking && latestRelease?.tag_name) {
return {
latestRelease,
isNewVersion: isNewVersion(latestRelease.tag_name),
};
}
const isNewVersion =
!checking &&
latestRelease !== undefined &&
latestRelease.tag_name !== ignoredVersion &&
isNewerVersion(latestRelease.tag_name);
return {
latestRelease: undefined,
isNewVersion: false,
latestRelease,
isNewVersion,
ignoreVersion,
};
};
+29 -12
View File
@@ -93,23 +93,32 @@ function CustomBottomTabBar({
style={styles.pressable}
>
{/* Icon */}
<Animated.View
<View
style={[
styles.iconContainer,
{
transitionProperty: ['width', 'backgroundColor'],
transitionDuration: 250,
transitionTimingFunction: 'ease-in-out',
marginBottom: showLabel ? 4 : 20,
width: isFocused ? 64 : 32,
backgroundColor: isFocused
? theme.primaryContainer
: transparentBg,
},
{ marginBottom: showLabel ? 4 : 20 },
]}
>
{/* The indicator is absolutely positioned and animated with a
transform so that growing it never re-runs layout, which
would otherwise make the icon and label tremble sideways as
their rounded pixel positions shift on every frame. */}
<Animated.View
style={[
styles.indicator,
{
transitionProperty: ['transform', 'backgroundColor'],
transitionDuration: 250,
transitionTimingFunction: 'ease-in-out',
transform: [{ scaleX: isFocused ? 1 : 0.5 }],
backgroundColor: isFocused
? theme.primaryContainer
: transparentBg,
},
]}
/>
{renderIcon({ color: iconColor, route })}
</Animated.View>
</View>
{/* Label */}
{showLabel ? (
@@ -155,7 +164,15 @@ const styles = StyleSheet.create({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: 64,
height: 32,
},
indicator: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: 16,
},
label: {
-1
View File
@@ -33,7 +33,6 @@ const SelectableChip: React.FC<SelectableChipProps> = ({
showSelectedCheck={showCheckIcon}
style={styles.chip}
textStyle={{ fontFamily: customFontFamily }}
selectedColor={theme.primary}
theme={{ colors: theme }}
mode={selected ? 'flat' : mode}
>
+18 -1
View File
@@ -4,10 +4,27 @@ import { View, StyleSheet } from 'react-native';
const Row = ({
children,
style = {},
horizontalSpacing,
verticalSpacing,
}: {
children?: React.ReactNode;
style?: any;
}) => <View style={[styles.row, style]}>{children}</View>;
horizontalSpacing?: number | `${number}%`;
verticalSpacing?: number | `${number}%`;
}) => (
<View
style={[
styles.row,
style,
{
paddingHorizontal: horizontalSpacing,
paddingVertical: verticalSpacing,
},
]}
>
{children}
</View>
);
export { Row };
+8 -2
View File
@@ -1,16 +1,19 @@
import React from 'react';
import { Pressable, View, StyleSheet } from 'react-native';
import { View, StyleSheet } from 'react-native';
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
import { ThemeColors } from '../../theme/types';
import Color from 'color';
import { MaterialDesignIconName } from '@type/icon';
import { Pressable } from 'react-native-gesture-handler';
// --- Dynamic style helpers ---
const getToggleButtonPressableStyle = (
selected: boolean,
theme: ThemeColors,
disabled?: boolean,
) => ({
opacity: disabled ? 0.6 : 1,
backgroundColor: selected
? Color(theme.primary).alpha(0.12).string()
: 'transparent',
@@ -24,6 +27,7 @@ interface ToggleButtonProps {
theme: ThemeColors;
color?: string;
onPress: () => void;
disabled?: boolean;
}
export const ToggleButton: React.FC<ToggleButtonProps> = ({
@@ -32,15 +36,17 @@ export const ToggleButton: React.FC<ToggleButtonProps> = ({
theme,
color,
onPress,
disabled,
}) => (
<View style={styles.toggleButtonContainer}>
<Pressable
android_ripple={{ color: theme.rippleColor }}
style={[
styles.toggleButtonPressable,
getToggleButtonPressableStyle(selected, theme),
getToggleButtonPressableStyle(selected, theme, disabled),
]}
onPress={onPress}
disabled={disabled}
>
<MaterialCommunityIcons
name={icon}
@@ -0,0 +1,96 @@
import './mocks';
import { render, screen, fireEvent } from '@testing-library/react-native';
import { ToggleButton } from '../ToggleButton';
// Mock native icon module
jest.mock('@react-native-vector-icons/material-design-icons', () => {
const React = require('react');
const { View } = require('react-native');
const MockIcon = (props: Record<string, unknown>) =>
React.createElement(View, { ...props, testID: 'icon' });
MockIcon.displayName = 'MaterialCommunityIcons';
return { __esModule: true, default: MockIcon };
});
const mockTheme = {
id: 0,
name: 'test',
isDark: false,
primary: '#6200ee',
onPrimary: '#fff',
primaryContainer: '#e8def8',
onPrimaryContainer: '#21005d',
secondary: '#625b71',
onSecondary: '#fff',
secondaryContainer: '#e8def8',
onSecondaryContainer: '#1d192b',
tertiary: '#7d5260',
onTertiary: '#fff',
tertiaryContainer: '#ffd8e4',
onTertiaryContainer: '#31111d',
error: '#f00',
onError: '#fff',
errorContainer: '#f9dedc',
onErrorContainer: '#410e0b',
background: '#fff',
onBackground: '#000',
surface: '#f5f5f5',
onSurface: '#000',
surfaceVariant: '#e8e8e8',
onSurfaceVariant: '#666',
outline: '#ccc',
outlineVariant: '#cac4d0',
shadow: '#000',
scrim: '#000',
inverseSurface: '#313033',
inverseOnSurface: '#f4eff4',
inversePrimary: '#d0bcff',
surfaceDisabled: 'rgba(28, 27, 31, 0.12)',
onSurfaceDisabled: 'rgba(28, 27, 31, 0.38)',
backdrop: 'rgba(0, 0, 0, 0.4)',
rippleColor: 'rgba(0,0,0,0.1)',
};
describe('ToggleButton', () => {
it('renders icon via MaterialCommunityIcons', () => {
render(
<ToggleButton icon="cog" selected={false} theme={mockTheme} onPress={() => { }} />,
);
expect(screen.getByTestId('icon')).toBeTruthy();
});
it('selected state: icon color is theme.primary', () => {
render(
<ToggleButton icon="cog" selected={true} theme={mockTheme} onPress={() => { }} />,
);
const icon = screen.getByTestId('icon');
expect(icon.props.color).toBe(mockTheme.primary);
});
it('unselected state: icon color is theme.onSurface', () => {
render(
<ToggleButton icon="cog" selected={false} theme={mockTheme} onPress={() => { }} />,
);
const icon = screen.getByTestId('icon');
expect(icon.props.color).toBe(mockTheme.onSurface);
});
it('calls onPress on press', () => {
const onPress = jest.fn();
render(
<ToggleButton icon="cog" selected={false} theme={mockTheme} onPress={onPress} />,
);
fireEvent.press(screen.getByTestId('icon'));
expect(onPress).toHaveBeenCalledTimes(1);
});
it('disabled: press does not call onPress', () => {
const onPress = jest.fn();
render(
<ToggleButton icon="cog" selected={false} theme={mockTheme} onPress={onPress} disabled={true} />,
);
fireEvent.press(screen.getByTestId('icon'));
expect(onPress).not.toHaveBeenCalled();
});
});
+30
View File
@@ -0,0 +1,30 @@
jest.mock('@utils/showToast', () => ({
showToast: jest.fn(),
}));
jest.mock('@i18n/translations', () => ({
getString: jest.fn(key => key),
}));
jest.mock('@utils/parseChapterNumber', () => ({
parseChapterNumber: jest.fn(() => 1),
}));
jest.mock('@utils/Storages', () => ({
NOVEL_STORAGE: '/mock/storage',
}));
jest.mock('@hooks/persisted/usePlugins');
jest.mock('@hooks/persisted/useTracker');
jest.mock('@hooks/persisted/useDownload');
jest.mock('@hooks/persisted/useUserAgent');
jest.mock('@hooks/persisted/useSettings');
jest.mock('@hooks/persisted/useNovelSettings');
jest.mock('@hooks/persisted/useHistory');
jest.mock('@hooks/persisted/useImport');
jest.mock('@hooks/persisted/useSelfHost');
jest.mock('@hooks/persisted/useTheme');
jest.mock('@hooks/persisted/useTrackedNovel');
jest.mock('@hooks/persisted/useUpdates');
jest.mock('@services/plugin/fetch');
jest.mock('@components/Context/LibraryContext');
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import { getString } from '@i18n/translations';
@@ -11,6 +11,7 @@ interface ConfirmationDialogProps {
confirmLabel: string;
cancelLabel?: string;
confirmTone?: DialogActionTone;
confirmFirst?: boolean;
onConfirm: () => void | Promise<void>;
onDismiss: () => void;
}
@@ -22,25 +23,52 @@ const ConfirmationDialog: React.FC<ConfirmationDialogProps> = ({
confirmLabel,
cancelLabel = getString('common.cancel'),
confirmTone = 'danger',
confirmFirst = false,
onDismiss,
onConfirm,
}) => {
const handleConfirm = () => {
void onConfirm();
onDismiss();
const [isConfirming, setIsConfirming] = useState(false);
const handleConfirm = async () => {
setIsConfirming(true);
try {
await onConfirm();
onDismiss();
} finally {
setIsConfirming(false);
}
};
const cancelAction = (
<Dialog.Action key="cancel" disabled={isConfirming} onPress={onDismiss}>
{cancelLabel}
</Dialog.Action>
);
const confirmAction = (
<Dialog.Action
key="confirm"
disabled={isConfirming}
loading={isConfirming}
tone={confirmTone}
onPress={handleConfirm}
>
{confirmLabel}
</Dialog.Action>
);
return (
<Dialog.Root visible={visible} onDismiss={onDismiss}>
<Dialog.Root
visible={visible}
onDismiss={isConfirming ? () => {} : onDismiss}
>
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
{message ? <Dialog.Description>{message}</Dialog.Description> : null}
</Dialog.Header>
<Dialog.Actions>
<Dialog.Action onPress={onDismiss}>{cancelLabel}</Dialog.Action>
<Dialog.Action tone={confirmTone} onPress={handleConfirm}>
{confirmLabel}
</Dialog.Action>
{confirmFirst
? [confirmAction, cancelAction]
: [cancelAction, confirmAction]}
</Dialog.Actions>
</Dialog.Root>
);
+22 -3
View File
@@ -4,6 +4,7 @@ import {
UseLibraryReturnType,
} from '@screens/library/hooks/useLibrary';
import { useLibrarySettings } from '@hooks/persisted';
import SetCategoriesModal from '@screens/novel/components/SetCategoriesModal';
// type Library = Category & { novels: LibraryNovelInfo[] };
@@ -11,6 +12,8 @@ type LibraryContextType = UseLibraryReturnType & {
settings: ReturnType<typeof useLibrarySettings>;
};
const EMPTY_NOVEL_IDS: number[] = [];
const defaultValue = {} as LibraryContextType;
const LibraryContext = createContext<LibraryContextType>(defaultValue);
@@ -21,11 +24,27 @@ export function LibraryContextProvider({
}) {
const useLibraryParams = useLibrary();
const settings = useLibrarySettings();
const {
pendingLibraryAddition,
cancelPendingLibraryAddition,
confirmPendingLibraryAddition,
} = useLibraryParams;
return (
<LibraryContext.Provider value={{ ...useLibraryParams, settings }}>
{children}
</LibraryContext.Provider>
<>
<LibraryContext.Provider value={{ ...useLibraryParams, settings }}>
{children}
</LibraryContext.Provider>
{pendingLibraryAddition ? (
<SetCategoriesModal
novelIds={EMPTY_NOVEL_IDS}
initialCategoryIds={pendingLibraryAddition.initialCategoryIds}
closeModal={cancelPendingLibraryAddition}
onSubmit={confirmPendingLibraryAddition}
visible
/>
) : null}
</>
);
}
@@ -8,7 +8,10 @@ const defaultLibraryContext = {
refreshCategories: jest.fn().mockResolvedValue(undefined),
setLibrary: jest.fn(),
novelInLibrary: jest.fn(() => false),
switchNovelToLibrary: jest.fn().mockResolvedValue(undefined),
switchNovelToLibrary: jest.fn().mockResolvedValue(true),
pendingLibraryAddition: undefined,
cancelPendingLibraryAddition: jest.fn(),
confirmPendingLibraryAddition: jest.fn().mockResolvedValue(undefined),
refetchLibrary: jest.fn(),
setLibrarySearchText: jest.fn(),
settings: {
@@ -0,0 +1,90 @@
import React from 'react';
import { Pressable, StyleSheet, View, ViewStyle } from 'react-native';
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
import Color from 'color';
import { ThemeColors } from '../../theme/types';
import { MaterialDesignIconName } from '@type/icon';
import Animated, {
SharedValue,
useAnimatedStyle,
withTiming,
} from 'react-native-reanimated';
const AnimatedIcon = Animated.createAnimatedComponent(MaterialCommunityIcons);
type Props = {
name: MaterialDesignIconName;
color?: string;
size?: number;
disabled?: boolean;
padding?: number;
onPress?: () => void;
theme: ThemeColors;
style?: ViewStyle;
rotation?: SharedValue<number>;
scale?: SharedValue<number>;
};
const AnimatedIconButton: React.FC<Props> = ({
name,
color,
size = 24,
padding = 8,
onPress,
disabled,
theme,
style,
rotation,
scale: _scale,
}) => {
const IconStyle = useAnimatedStyle(() => {
const rotate = rotation
? withTiming(rotation.value + 'deg', { duration: 250 })
: '0deg';
const scale = _scale ? withTiming(_scale.value, { duration: 250 }) : 1;
return {
textAlign: 'center',
transform: [
{
rotate,
},
{
scale,
},
],
};
});
return (
<View style={[styles.container, style]}>
<Pressable
style={[styles.pressable, { padding }]}
onPress={onPress}
disabled={disabled}
android_ripple={
onPress
? { color: Color(theme.primary).alpha(0.12).string() }
: undefined
}
>
<AnimatedIcon
name={name}
size={size}
color={disabled ? theme.outline : color || theme.onSurface}
style={IconStyle}
/>
</Pressable>
</View>
);
};
export default React.memo(AnimatedIconButton);
const styles = StyleSheet.create({
container: {
borderRadius: 50,
overflow: 'hidden',
},
pressable: {
padding: 8,
},
});
+3
View File
@@ -16,6 +16,7 @@ interface ListViewProps {
onPress: () => void;
isSelected?: boolean;
onLongPress?: () => void;
continueReadingButton?: React.ReactNode;
}
const ListView = ({
@@ -27,6 +28,7 @@ const ListView = ({
onPress,
isSelected,
onLongPress,
continueReadingButton,
}: ListViewProps) => {
const fadedImage = { opacity: inLibraryBadge ? 0.5 : 1 };
return (
@@ -58,6 +60,7 @@ const ListView = ({
{unreadBadge}
{inLibraryBadge}
</View>
{continueReadingButton}
</Pressable>
);
};
-67
View File
@@ -1,67 +0,0 @@
import React, { useState } from 'react';
import { StyleSheet, Text, useWindowDimensions } from 'react-native';
import * as Linking from 'expo-linking';
import { ScrollView } from 'react-native-gesture-handler';
import { getString } from '@i18n/translations';
import { useTheme } from '@hooks/persisted';
import { Dialog } from '@components';
interface NewUpdateDialogProps {
newVersion: {
tag_name: string;
body: string;
downloadUrl: string;
};
}
const NewUpdateDialog: React.FC<NewUpdateDialogProps> = ({ newVersion }) => {
const [newUpdateDialog, showNewUpdateDialog] = useState(true);
const theme = useTheme();
const modalHeight = useWindowDimensions().height / 2;
return (
<Dialog.Root
visible={newUpdateDialog}
onDismiss={() => showNewUpdateDialog(false)}
>
<Dialog.Title>
{`${getString('common.newUpdateAvailable')} ${newVersion.tag_name}`}
</Dialog.Title>
<Dialog.ScrollArea>
<ScrollView
contentContainerStyle={styles.scrollContent}
style={{ height: modalHeight }}
>
<Text style={[styles.body, { color: theme.onSurfaceVariant }]}>
{newVersion.body.split('\n').join('\n\n')}
</Text>
</ScrollView>
</Dialog.ScrollArea>
<Dialog.Actions>
<Dialog.Action
title={getString('common.cancel')}
onPress={() => showNewUpdateDialog(false)}
/>
<Dialog.Action
title={getString('common.install')}
onPress={() => Linking.openURL(newVersion.downloadUrl)}
/>
</Dialog.Actions>
</Dialog.Root>
);
};
export default NewUpdateDialog;
const styles = StyleSheet.create({
body: {
fontSize: 15,
fontWeight: '500',
},
scrollContent: {
paddingHorizontal: 24,
},
});
+43
View File
@@ -8,6 +8,7 @@ import {
} from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Color from 'color';
import ListView from './ListView';
import { DisplayModes } from '@screens/library/constants/constants';
@@ -19,6 +20,7 @@ import { getString } from '@i18n/translations';
import SourceScreenSkeletonLoading from '@screens/browse/loadingAnimation/SourceScreenSkeletonLoading';
import NovelCoverImage from './NovelCoverImage';
import { useNovelCoverLayout } from './NovelCoverLayoutContext';
import MaterialCommunityIcons from '@react-native-vector-icons/material-design-icons';
interface UnreadBadgeProps {
showDownloadBadges: boolean;
@@ -61,6 +63,7 @@ interface INovelCover<TNovel> {
selectedNovelIds?: number[];
globalSearch?: boolean;
imageRequestInit?: ImageRequestInit;
onContinueReading?: () => void;
}
function isFromDB(
@@ -84,6 +87,7 @@ function NovelCover<
globalSearch,
selectedNovelIds,
imageRequestInit,
onContinueReading,
}: INovelCover<TNovel>) {
const selectionActive =
hasSelection ?? (selectedNovelIds != null && selectedNovelIds.length > 0);
@@ -108,6 +112,25 @@ function NovelCover<
[imageRequestInit],
);
const continueReadingButton =
onContinueReading && !selectionActive ? (
<Pressable
accessibilityLabel={getString('novelScreen.continueReading')}
accessibilityRole="button"
android_ripple={{ color: theme.rippleColor }}
onPress={event => {
event.stopPropagation();
onContinueReading();
}}
style={[
styles.continueReadingButton,
{ backgroundColor: Color(theme.primary).alpha(0.9).string() },
]}
>
<MaterialCommunityIcons name="play" size={20} color={theme.onPrimary} />
</Pressable>
) : null;
if (item.completeRow) {
if (!addSkeletonLoading) {
return <></>;
@@ -197,6 +220,13 @@ function NovelCover<
width={coverWidth}
/>
) : null}
{continueReadingButton ? (
<View
style={[styles.continueReadingOverlay, { top: coverHeight - 40 }]}
>
{continueReadingButton}
</View>
) : null}
</Pressable>
</View>
) : (
@@ -227,6 +257,7 @@ function NovelCover<
onPress={selectionActive ? selectNovel : onPress}
onLongPress={selectNovel}
isSelected={isSelected}
continueReadingButton={continueReadingButton}
/>
);
}
@@ -373,6 +404,18 @@ const styles = StyleSheet.create({
position: 'absolute',
right: 4,
},
continueReadingButton: {
alignItems: 'center',
borderRadius: 8,
height: 36,
justifyContent: 'center',
overflow: 'hidden',
width: 36,
},
continueReadingOverlay: {
position: 'absolute',
right: 12,
},
downloadBadge: {
borderBottomLeftRadius: 4,
borderTopLeftRadius: 4,
+10 -15
View File
@@ -1,10 +1,10 @@
import React, { useMemo } from 'react';
import { StyleSheet } from 'react-native';
import {
FlashList,
type FlashListProps,
type ListRenderItem,
} from '@shopify/flash-list';
LegendList,
type LegendListProps,
type LegendListRenderItemProps,
} from '@legendapp/list/react-native';
import { NovelItem } from '@plugins/types';
import { NovelInfo } from '../database/types';
import { DisplayModes } from '@screens/library/constants/constants';
@@ -13,14 +13,16 @@ import {
useNovelCoverLayoutValue,
} from './NovelCoverLayoutContext';
export type NovelListRenderItem = ListRenderItem<NovelInfo | NovelItem>;
export type NovelListRenderItem = (
info: LegendListRenderItemProps<NovelInfo | NovelItem>,
) => React.ReactNode;
export type NovelListDataItem = (NovelInfo | NovelItem) & {
completeRow?: number;
};
interface NovelListProps
extends Omit<FlashListProps<NovelInfo | NovelItem>, 'data'> {
extends Omit<LegendListProps<NovelInfo | NovelItem>, 'data'> {
inSource?: boolean;
data: NovelListDataItem[];
}
@@ -52,14 +54,6 @@ export const extendNovelList = (
}
}
extension.push({
id: undefined,
cover: '',
name: '',
path: '__loading-row',
completeRow: 2,
});
return [...data, ...extension];
};
@@ -76,7 +70,7 @@ const NovelList: React.FC<NovelListProps> = props => {
return (
<NovelCoverLayoutProvider value={layout}>
<FlashList
<LegendList
contentContainerStyle={[
!isListView && styles.listView,
styles.flatListCont,
@@ -84,6 +78,7 @@ const NovelList: React.FC<NovelListProps> = props => {
numColumns={numColumns}
key={numColumns}
keyExtractor={novelListKeyExtractor}
estimatedItemSize={layout.coverHeight}
{...listProps}
data={extendedNovelList}
/>
+12 -1
View File
@@ -25,6 +25,8 @@ const THUMB_OFFSET_ON =
interface SwitchProps {
accessible?: boolean;
accessibilityLabel?: string;
containerStyle?: StyleProp<ViewStyle>;
value: boolean;
onValueChange?: () => void;
style?: StyleProp<ViewStyle>;
@@ -32,6 +34,8 @@ interface SwitchProps {
const Switch = ({
accessible = true,
accessibilityLabel,
containerStyle,
value,
onValueChange,
style,
@@ -110,7 +114,14 @@ const Switch = ({
}));
return (
<Pressable accessible={accessible} onPress={onValueChange}>
<Pressable
accessible={accessible}
accessibilityLabel={accessibilityLabel}
accessibilityRole={accessible ? 'switch' : undefined}
accessibilityState={accessible ? { checked: value } : undefined}
style={containerStyle}
onPress={onValueChange}
>
<Animated.View
style={[styles.track, style, trackColorStyle, trackBorderStyle]}
>
+6 -1
View File
@@ -14,7 +14,9 @@ interface SwitchItemProps {
value: boolean;
label: string;
description?: string;
descriptionNumberOfLines?: number;
onPress: () => void;
onLongPress?: () => void;
theme: ThemeColors;
style?: StyleProp<ViewStyle>;
}
@@ -22,7 +24,9 @@ interface SwitchItemProps {
const SwitchItem: React.FC<SwitchItemProps> = ({
label,
description,
descriptionNumberOfLines,
onPress,
onLongPress,
theme,
value,
style,
@@ -34,11 +38,12 @@ const SwitchItem: React.FC<SwitchItemProps> = ({
android_ripple={{ color: theme.rippleColor }}
style={[styles.container, style]}
onPress={onPress}
onLongPress={onLongPress}
>
<View style={styles.labelContainer}>
<Text style={[{ color: theme.onSurface }, styles.label]}>{label}</Text>
{description ? (
<Text style={[styles.description, { color: theme.onSurfaceVariant }]}>
<Text numberOfLines={descriptionNumberOfLines} style={[styles.description, { color: theme.onSurfaceVariant }]}>
{description}
</Text>
) : null}
@@ -0,0 +1,122 @@
import './mocks';
import { render, screen, fireEvent } from '@testing-library/react-native';
import SwitchItem from '../SwitchItem';
// Mock reanimated — setUpTests in global setup may have failed.
jest.mock('react-native-reanimated', () => {
const { View } = require('react-native');
return {
useSharedValue: (init: unknown) => ({ value: init }),
useAnimatedStyle: (fn: () => Record<string, unknown>) => fn(),
useDerivedValue: (fn: () => unknown) => fn(),
withTiming: (val: unknown) => val,
withSpring: (val: unknown) => val,
interpolateColor: () => 'transparent',
createAnimatedComponent: (c: unknown) => c,
default: { View },
__esModule: true,
};
});
const mockUseTheme = jest.fn();
jest.mock('@hooks/persisted', () => ({
useTheme: () => mockUseTheme(),
}));
const mockTheme = {
id: 0,
name: 'test',
isDark: false,
primary: '#6200ee',
onPrimary: '#fff',
primaryContainer: '#e8def8',
onPrimaryContainer: '#21005d',
secondary: '#625b71',
onSecondary: '#fff',
secondaryContainer: '#e8def8',
onSecondaryContainer: '#1d192b',
tertiary: '#7d5260',
onTertiary: '#fff',
tertiaryContainer: '#ffd8e4',
onTertiaryContainer: '#31111d',
error: '#f00',
onError: '#fff',
errorContainer: '#f9dedc',
onErrorContainer: '#410e0b',
background: '#fff',
onBackground: '#000',
surface: '#f5f5f5',
onSurface: '#000',
surfaceVariant: '#e8e8e8',
onSurfaceVariant: '#666',
outline: '#ccc',
outlineVariant: '#cac4d0',
shadow: '#000',
scrim: '#000',
inverseSurface: '#313033',
inverseOnSurface: '#f4eff4',
inversePrimary: '#d0bcff',
surfaceDisabled: 'rgba(28, 27, 31, 0.12)',
onSurfaceDisabled: 'rgba(28, 27, 31, 0.38)',
backdrop: 'rgba(0, 0, 0, 0.4)',
rippleColor: 'rgba(0,0,0,0.1)',
};
describe('SwitchItem', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseTheme.mockReturnValue(mockTheme);
});
it('renders label text', () => {
render(<SwitchItem label="Test Label" value={false} onPress={() => { }} theme={mockTheme} />);
expect(screen.getByText('Test Label')).toBeTruthy();
});
it('renders description when provided', () => {
render(
<SwitchItem
label="Test"
description="A helpful description"
value={false}
onPress={() => { }}
theme={mockTheme}
/>,
);
expect(screen.getByText('A helpful description')).toBeTruthy();
});
it('does not render description when omitted', () => {
render(<SwitchItem label="Test" value={false} onPress={() => { }} theme={mockTheme} />);
expect(screen.queryByText('A helpful description')).toBeNull();
});
it('calls onPress on press', () => {
const onPress = jest.fn();
render(<SwitchItem label="Pressable" value={false} onPress={onPress} theme={mockTheme} />);
fireEvent.press(screen.getByText('Pressable'));
expect(onPress).toHaveBeenCalledTimes(1);
});
it('calls onLongPress on long press', () => {
const onLongPress = jest.fn();
render(
<SwitchItem
label="Long Press"
value={false}
onPress={() => { }}
onLongPress={onLongPress}
theme={mockTheme}
/>,
);
fireEvent(screen.getByText('Long Press'), 'onLongPress');
expect(onLongPress).toHaveBeenCalledTimes(1);
});
it('has correct accessibility role and label', () => {
render(<SwitchItem label="Accessible" value={false} onPress={() => { }} theme={mockTheme} />);
const element = screen.getByRole('switch', { name: 'Accessible' });
expect(element).toBeTruthy();
});
});
+30
View File
@@ -0,0 +1,30 @@
jest.mock('@utils/showToast', () => ({
showToast: jest.fn(),
}));
jest.mock('@i18n/translations', () => ({
getString: jest.fn(key => key),
}));
jest.mock('@utils/parseChapterNumber', () => ({
parseChapterNumber: jest.fn(() => 1),
}));
jest.mock('@utils/Storages', () => ({
NOVEL_STORAGE: '/mock/storage',
}));
jest.mock('@hooks/persisted/usePlugins');
jest.mock('@hooks/persisted/useTracker');
jest.mock('@hooks/persisted/useDownload');
jest.mock('@hooks/persisted/useUserAgent');
jest.mock('@hooks/persisted/useSettings');
jest.mock('@hooks/persisted/useNovelSettings');
jest.mock('@hooks/persisted/useHistory');
jest.mock('@hooks/persisted/useImport');
jest.mock('@hooks/persisted/useSelfHost');
jest.mock('@hooks/persisted/useTheme');
jest.mock('@hooks/persisted/useTrackedNovel');
jest.mock('@hooks/persisted/useUpdates');
jest.mock('@services/plugin/fetch');
jest.mock('@components/Context/LibraryContext');
@@ -0,0 +1,132 @@
jest.mock('react-native-gesture-handler', () => {
const RN = require('react-native');
const View = RN.View || require('react-native').View;
return {
TextInput: RN.TextInput,
GestureHandlerRootView: View,
Swipeable: View,
Pressable: RN.Pressable || View,
State: { BEGAN: 0, ACTIVE: 1, END: 2 },
PanGestureHandler: View,
NativeViewGestureHandler: View,
TapGestureHandler: View,
LongPressGestureHandler: View,
PinchGestureHandler: View,
RotationGestureHandler: View,
FlingGestureHandler: View,
Directions: { UP: 1, DOWN: 2, LEFT: 4, RIGHT: 8 },
gestureHandlerRootHOC: (c: unknown) => c,
createNativeWrapper: (c: unknown) => c,
// Default export used by some imports
default: {
TextInput: RN.TextInput,
GestureHandlerRootView: View,
},
};
});
import './mocks';
import { render, screen, fireEvent } from '@testing-library/react-native';
import TextInput from '../index';
const mockUseTheme = jest.fn();
jest.mock('@hooks/persisted', () => ({
useTheme: () => mockUseTheme(),
}));
const mockTheme = {
primary: '#6200ee',
onSurface: '#000',
onSurfaceVariant: '#666',
surfaceVariant: '#e8e8e8',
rippleColor: 'rgba(0,0,0,0.1)',
outline: '#ccc',
error: '#f00',
background: '#fff',
surface: '#f5f5f5',
onPrimary: '#fff',
onBackground: '#000',
};
describe('TextInput', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseTheme.mockReturnValue(mockTheme);
});
it('renders with placeholder', () => {
render(<TextInput placeholder="Enter text" />);
expect(screen.getByPlaceholderText('Enter text')).toBeTruthy();
});
it('on focus: border color changes to theme.primary, border width 2', () => {
render(<TextInput placeholder="Test" />);
const input = screen.getByPlaceholderText('Test');
fireEvent(input, 'focus');
// The style array contains the dynamic style object as first element
const styleArray = input.props.style;
expect(styleArray[0].borderColor).toBe(mockTheme.primary);
expect(styleArray[0].borderWidth).toBe(2);
});
it('on blur: border color reverts to theme.outline, border width 1', () => {
render(<TextInput placeholder="Test" />);
const input = screen.getByPlaceholderText('Test');
fireEvent(input, 'focus');
fireEvent(input, 'blur');
const styleArray = input.props.style;
expect(styleArray[0].borderColor).toBe(mockTheme.outline);
expect(styleArray[0].borderWidth).toBe(1);
});
it('error state: border color is theme.error, border width 2 regardless of focus', () => {
render(<TextInput placeholder="Test" error />);
const input = screen.getByPlaceholderText('Test');
const styleArray = input.props.style;
expect(styleArray[0].borderColor).toBe(mockTheme.error);
expect(styleArray[0].borderWidth).toBe(2);
// Blur should not change error styling
fireEvent(input, 'blur');
expect(styleArray[0].borderColor).toBe(mockTheme.error);
expect(styleArray[0].borderWidth).toBe(2);
});
it('forceFocused prop: shows focused styling even without actual focus', () => {
render(<TextInput placeholder="Test" forceFocused />);
const input = screen.getByPlaceholderText('Test');
const styleArray = input.props.style;
expect(styleArray[0].borderColor).toBe(mockTheme.primary);
expect(styleArray[0].borderWidth).toBe(2);
// Blur should not change forceFocused styling
fireEvent(input, 'blur');
expect(styleArray[0].borderColor).toBe(mockTheme.primary);
});
it('merges custom style prop with default styles', () => {
render(<TextInput placeholder="Test" style={{ fontSize: 20 }} />);
const input = screen.getByPlaceholderText('Test');
const styleArray = input.props.style;
// Default textInput style (index 1) should have inherited styles
// Custom style is last (index 2)
expect(styleArray[2]).toEqual({ fontSize: 20 });
});
it('passes through other TextInput props (onChangeText)', () => {
const onChangeText = jest.fn();
render(<TextInput placeholder="Test" onChangeText={onChangeText} />);
const input = screen.getByPlaceholderText('Test');
fireEvent.changeText(input, 'new text');
expect(onChangeText).toHaveBeenCalledWith('new text');
});
});
@@ -0,0 +1,30 @@
jest.mock('@utils/showToast', () => ({
showToast: jest.fn(),
}));
jest.mock('@i18n/translations', () => ({
getString: jest.fn(key => key),
}));
jest.mock('@utils/parseChapterNumber', () => ({
parseChapterNumber: jest.fn(() => 1),
}));
jest.mock('@utils/Storages', () => ({
NOVEL_STORAGE: '/mock/storage',
}));
jest.mock('@hooks/persisted/usePlugins');
jest.mock('@hooks/persisted/useTracker');
jest.mock('@hooks/persisted/useDownload');
jest.mock('@hooks/persisted/useUserAgent');
jest.mock('@hooks/persisted/useSettings');
jest.mock('@hooks/persisted/useNovelSettings');
jest.mock('@hooks/persisted/useHistory');
jest.mock('@hooks/persisted/useImport');
jest.mock('@hooks/persisted/useSelfHost');
jest.mock('@hooks/persisted/useTheme');
jest.mock('@hooks/persisted/useTrackedNovel');
jest.mock('@hooks/persisted/useUpdates');
jest.mock('@services/plugin/fetch');
jest.mock('@components/Context/LibraryContext');

Some files were not shown because too many files have changed in this diff Show More