Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae8d055689 | |||
| 1607f80a0a | |||
| a0c20e209c | |||
| 648477931b | |||
| e42e8f66f2 | |||
| de5358727a | |||
| 706459783f | |||
| 1ca9a679cf | |||
| 920f88aee4 | |||
| cef3ae5bdf | |||
| 39c788e576 | |||
| d40713ac99 | |||
| f53bed7190 | |||
| 0b9c415681 | |||
| 38003c59e0 | |||
| f1119d8c30 | |||
| a5e2b4d4f0 | |||
| a12cba030d | |||
| 56d1140aad | |||
| 0379141304 | |||
| b6e9f7157d | |||
| edb3df719f | |||
| 9bde3df74d | |||
| 2426e6fe3e | |||
| 93209abebd | |||
| 158786ccd5 | |||
| 77be5624f2 | |||
| 4b580c90ef | |||
| cb1a5d9e82 | |||
| 7e20f7dccb | |||
| 0fab0143b1 | |||
| 5a0717d2c7 | |||
| 09652f9962 | |||
| eef58f212f | |||
| 523aa90c19 | |||
| eb310f7821 | |||
| af2a224781 | |||
| 990cd4f2eb | |||
| 3ac611f633 | |||
| 909504a721 | |||
| 63349de1bb | |||
| c6679b7f41 | |||
| 15560b67b1 | |||
| 7883b28cd9 | |||
| 64707409bb | |||
| 7f1f76408b | |||
| 51560195ba | |||
| 084dcccabc | |||
| 3ad6e372fd | |||
| 179feb56ee | |||
| 67e01bc2df | |||
| 3ece098b94 | |||
| 586e085141 | |||
| e0c89cdd9f | |||
| 3bf025108a | |||
| f69e5d6a7e | |||
| a727c229ca | |||
| 57eca11a9e | |||
| 197d8670f8 | |||
| 675f19ef90 | |||
| 8a12529ba8 | |||
| c3482a8510 | |||
| e4246dee59 | |||
| 23f9b183be | |||
| 1eb8c587c7 | |||
| c3b75ebeb2 | |||
| 91358ad3dd | |||
| 13885320ae | |||
| b9d1abcf27 | |||
| c3260e8e01 | |||
| c0877a9b06 | |||
| 96fb52df17 | |||
| 76106317b6 | |||
| e75f4fba5d | |||
| 4dc933ee5a | |||
| bfb12b04aa | |||
| b7639ab55a |
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
pnpm run type-check
|
||||
|
||||
@@ -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).
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 &&
|
||||
|
||||
Vendored
+2
@@ -29,6 +29,8 @@ export interface Reader {
|
||||
finished: string;
|
||||
nextChapter: string;
|
||||
noNextChapter: string;
|
||||
removeText: string;
|
||||
replaceText: string;
|
||||
};
|
||||
|
||||
setAdjacentChapters: (adjacent: {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,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
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -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)
|
||||
}
|
||||
}
|
||||
+68
-9
@@ -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)
|
||||
|
||||
+75
@@ -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"
|
||||
}
|
||||
|
||||
+2
-1
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+111
-41
@@ -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)
|
||||
|
||||
+74
-38
@@ -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
|
||||
|
||||
|
||||
+21
-10
@@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
+57
@@ -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())
|
||||
}
|
||||
}
|
||||
+34
@@ -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
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package expo.modules.nativecrashlogs
|
||||
|
||||
import androidx.core.content.FileProvider
|
||||
|
||||
class CrashLogFileProvider : FileProvider(R.xml.crash_log_paths)
|
||||
+15
@@ -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]")
|
||||
}
|
||||
+48
@@ -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)
|
||||
}
|
||||
+73
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+163
@@ -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>
|
||||
+26
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import NativeCrashLogs from './src/NativeCrashLogsModule';
|
||||
|
||||
export default NativeCrashLogs;
|
||||
@@ -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',
|
||||
);
|
||||
+263
-8
@@ -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 ?? ""
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+37
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
+7
-9
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
+116
-8
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Generated
+543
-198
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));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AppUpdateChecker';
|
||||
+69
-55
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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]}
|
||||
>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user