Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 89986d5d17 | |||
| ae8d055689 | |||
| 1607f80a0a |
@@ -114,3 +114,6 @@ release-artifacts/
|
||||
!plugins/android/
|
||||
ios/
|
||||
!modules/**/ios/
|
||||
|
||||
# iOS: reader assets inlined into the JS bundle (scripts/embed-reader-assets.mjs)
|
||||
src/utils/readerAssets/embedded.generated.ts
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
> **open-lab fork:** branch `ios` adds an iOS build (sideloaded with SideStore and a free Apple ID) and preconfigures the open-lab plugin repository. See [docs/ios-sidestore.md](docs/ios-sidestore.md).
|
||||
|
||||
<p align="center">
|
||||
<a href="https://lnreader.app">
|
||||
<img src="./.github/readme-images/icon_new.png" align="center" width="128" />
|
||||
|
||||
@@ -51,6 +51,9 @@
|
||||
},
|
||||
"enableMinifyInReleaseBuilds": true,
|
||||
"enableShrinkResourcesInReleaseBuilds": true
|
||||
},
|
||||
"ios": {
|
||||
"deploymentTarget": "16.4"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -74,8 +77,14 @@
|
||||
"intentFilters": [
|
||||
{
|
||||
"action": "SEND",
|
||||
"category": ["DEFAULT"],
|
||||
"data": [{ "mimeType": "text/plain" }]
|
||||
"category": [
|
||||
"DEFAULT"
|
||||
],
|
||||
"data": [
|
||||
{
|
||||
"mimeType": "text/plain"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -85,9 +94,18 @@
|
||||
}
|
||||
},
|
||||
"ios": {
|
||||
"bundleIdentifier": "com.rajarsheechatterjee.LNReader",
|
||||
"bundleIdentifier": "br.dev.marcospaulo.lnreader",
|
||||
"supportsTablet": true,
|
||||
"infoPlist": {
|
||||
"UIBackgroundModes": ["audio"]
|
||||
"UIBackgroundModes": [
|
||||
"audio"
|
||||
],
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
},
|
||||
"UIFileSharingEnabled": true,
|
||||
"LSSupportsOpeningDocumentsInPlace": true,
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+42
-13
@@ -524,7 +524,8 @@ van.derive(() => {
|
||||
}
|
||||
});
|
||||
|
||||
window.pageReader = new (function () {
|
||||
var pageReader;
|
||||
window.pageReader = pageReader = new (function () {
|
||||
const config =
|
||||
typeof initialPageReaderConfig === 'undefined'
|
||||
? {}
|
||||
@@ -544,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)`;
|
||||
@@ -579,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;
|
||||
@@ -606,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,
|
||||
);
|
||||
|
||||
@@ -687,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', () => {
|
||||
@@ -905,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);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# LNReader on iOS (SideStore, free Apple ID)
|
||||
|
||||
This fork (`open-lab/lnreader`, branch `ios`) builds for iOS. Apple only lets you
|
||||
install your own builds signed with an Apple ID; with a free one the app is valid for
|
||||
7 days and has to be refreshed. SideStore does the signing and refreshing on the
|
||||
phone itself.
|
||||
|
||||
## What is different on iOS
|
||||
|
||||
| Works | Not available on iOS |
|
||||
|---|---|
|
||||
| Browsing, plugins (the open-lab repository is preconfigured on a fresh install), reading, Custom code (the illustrator snippet), downloads, EPUB reading, TTS, local library | Downloads and library updates in the background: tasks run while the app is open and continue when you return to it. No scheduled library updates or automatic backups. |
|
||||
| Downloaded chapters with their images | Page turning with the volume buttons (iOS gives apps no volume-key events) |
|
||||
| | Toast messages, "Share to LNReader", DNS-over-HTTPS, crash-log sharing |
|
||||
| | Folder and file pickers: EPUB export, backup create and restore |
|
||||
|
||||
## 1. Build the .ipa (Mac with Xcode)
|
||||
|
||||
```bash
|
||||
git clone https://git.marcospaulo.dev.br/open-lab/lnreader.git
|
||||
cd lnreader && git switch ios
|
||||
./scripts/build-ios-unsigned.sh # -> build/LNReader-unsigned.ipa
|
||||
```
|
||||
|
||||
Needs Xcode with its command line tools, Node 22.11 or newer, pnpm and CocoaPods
|
||||
(`brew install node pnpm cocoapods`). The first build takes a while (pods).
|
||||
|
||||
Publish it so the phone can download it:
|
||||
|
||||
```bash
|
||||
tea releases create --repo open-lab/lnreader --tag ios-$(jq -r .version package.json) \
|
||||
--title "iOS $(jq -r .version package.json)" --asset build/LNReader-unsigned.ipa
|
||||
```
|
||||
|
||||
## 2. Install SideStore on the iPhone (once)
|
||||
|
||||
Follow the current guide at <https://docs.sidestore.io> (the tools change). As of 2026:
|
||||
|
||||
1. On the iPhone: Settings > Privacy & Security > **Developer Mode** on (restart when asked).
|
||||
2. Install **LocalDevVPN** from the App Store; SideStore uses it to sign apps on the phone.
|
||||
3. On a computer (macOS, Windows or Linux) run **iloader**, connect the iPhone with a
|
||||
cable, sign in with your Apple ID and install SideStore. It also creates the
|
||||
pairing file SideStore needs.
|
||||
4. Open SideStore on the phone and sign in with the same Apple ID.
|
||||
|
||||
A free Apple ID allows 3 sideloaded apps at once (SideStore is one) and 10 new app IDs
|
||||
per week.
|
||||
|
||||
## 3. Install LNReader
|
||||
|
||||
1. On the iPhone, open the release page in Safari and download `LNReader-unsigned.ipa`
|
||||
(it lands in Files > Downloads).
|
||||
2. Turn LocalDevVPN on, open SideStore > My Apps > **+**, and pick the `.ipa`.
|
||||
3. Open LNReader. Under More > Settings > Reader > Custom code > JS, paste the
|
||||
illustrator snippet (it carries your access key, so it is not built in).
|
||||
|
||||
## Every 7 days
|
||||
|
||||
With LocalDevVPN on, open SideStore and tap **Refresh All** (or let a Shortcuts
|
||||
automation do it daily). If an app expires it stops opening; refreshing brings it back
|
||||
with its data intact. Updating LNReader: build and publish a new `.ipa`, then install
|
||||
it over the old one from SideStore.
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"platforms": ["apple", "android"],
|
||||
"apple": {
|
||||
"modules": ["NativeBackgroundTasksModule"]
|
||||
},
|
||||
"platforms": [
|
||||
"android"
|
||||
],
|
||||
"android": {
|
||||
"modules": ["expo.modules.nativebackgroundtasks.NativeBackgroundTasksModule"]
|
||||
"modules": [
|
||||
"expo.modules.nativebackgroundtasks.NativeBackgroundTasksModule"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
// iOS has no WorkManager equivalent that can run these tasks reliably in the
|
||||
// background, so on iOS the queue lives in JS: the same contract as the Android
|
||||
// module (NativeBackgroundTasksModule.kt), persisted in MMKV, and executed in the
|
||||
// foreground through the same headless runner. Tasks pause while the app is
|
||||
// suspended and continue when it returns; periodic library updates and automatic
|
||||
// backups are not scheduled on iOS.
|
||||
import { DeviceEventEmitter } from 'react-native';
|
||||
import { getMMKVObject, setMMKVObject } from '@utils/mmkv/mmkv';
|
||||
import type {
|
||||
NativeBackgroundTaskRecord,
|
||||
NativeBackgroundTaskSummary,
|
||||
} from './NativeBackgroundTasksModule';
|
||||
|
||||
export type { NativeBackgroundTaskRecord, NativeBackgroundTaskSummary };
|
||||
|
||||
type State =
|
||||
| 'queued'
|
||||
| 'running'
|
||||
| 'paused'
|
||||
| 'succeeded'
|
||||
| 'failed'
|
||||
| 'cancelled';
|
||||
|
||||
type Task = NativeBackgroundTaskRecord & {
|
||||
state: State;
|
||||
queueName: string;
|
||||
retryAt?: number; // a retried task waits RETRY_DELAY_MS before it may run
|
||||
};
|
||||
|
||||
type Result = { ok: true } | { ok: false; error: string; retry: boolean };
|
||||
|
||||
const STORE_KEY = 'IOS_BACKGROUND_TASKS';
|
||||
const ACTIVE: ReadonlySet<string> = new Set(['queued', 'running', 'paused']);
|
||||
const TERMINAL_RETENTION_MS = 24 * 60 * 60 * 1000;
|
||||
const MAX_CONCURRENT_DOWNLOADS = 3; // as LNReaderTaskWorker.kt
|
||||
const RETRY_DELAY_MS = 5000;
|
||||
|
||||
const load = () => getMMKVObject<Task[]>(STORE_KEY) || [];
|
||||
const save = (tasks: Task[]) => setMMKVObject(STORE_KEY, tasks);
|
||||
|
||||
const update = (taskId: string, change: (task: Task) => Task | null) => {
|
||||
const tasks = load();
|
||||
const index = tasks.findIndex(task => task.id === taskId);
|
||||
if (index < 0) return undefined;
|
||||
const next = change(tasks[index]);
|
||||
if (next) tasks[index] = { ...next, updatedAt: Date.now() };
|
||||
else tasks.splice(index, 1);
|
||||
save(tasks);
|
||||
return next ?? undefined;
|
||||
};
|
||||
|
||||
const requireTask = (taskId: string) => {
|
||||
const task = load().find(item => item.id === taskId);
|
||||
if (!task) throw new Error(`Unknown background task: ${taskId}`);
|
||||
return task;
|
||||
};
|
||||
|
||||
const summary = (task: Task): NativeBackgroundTaskSummary => ({
|
||||
id: task.id,
|
||||
type: task.type,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
state: task.state,
|
||||
progress: task.progress,
|
||||
progressText: task.progressText,
|
||||
attempt: task.attempt,
|
||||
createdAt: task.createdAt,
|
||||
updatedAt: task.updatedAt,
|
||||
});
|
||||
|
||||
const record = (task: Task): NativeBackgroundTaskRecord => ({
|
||||
...summary(task),
|
||||
payload: task.payload,
|
||||
checkpoint: task.checkpoint,
|
||||
});
|
||||
|
||||
const makeId = () =>
|
||||
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
||||
});
|
||||
|
||||
// ---- execution -------------------------------------------------------------
|
||||
|
||||
// Tasks whose runner is in progress, with how complete()/fail() settle them.
|
||||
const executing = new Map<string, (result: Result) => void>();
|
||||
|
||||
const interrupt = (taskId: string, action: 'pause' | 'cancel') => {
|
||||
if (executing.has(taskId)) {
|
||||
DeviceEventEmitter.emit('LNReaderTaskInterrupted', { taskId, action });
|
||||
}
|
||||
};
|
||||
|
||||
const settle = (taskId: string, result: Result) => {
|
||||
executing.get(taskId)?.(result);
|
||||
};
|
||||
|
||||
let pumping = false;
|
||||
|
||||
// Start every task that may run now: one per queue, at most
|
||||
// MAX_CONCURRENT_DOWNLOADS chapter downloads at once.
|
||||
const pump = () => {
|
||||
if (pumping) return;
|
||||
pumping = true;
|
||||
try {
|
||||
const tasks = load();
|
||||
const busyQueues = new Set(
|
||||
tasks.filter(t => t.state === 'running').map(t => t.queueName),
|
||||
);
|
||||
let downloads = tasks.filter(
|
||||
t => t.state === 'running' && t.type === 'DOWNLOAD_CHAPTER',
|
||||
).length;
|
||||
const now = Date.now();
|
||||
const waiting = tasks
|
||||
.filter(t => t.state === 'queued' && !(t.retryAt && t.retryAt > now))
|
||||
.sort((a, b) => a.createdAt - b.createdAt);
|
||||
for (const task of waiting) {
|
||||
if (busyQueues.has(task.queueName)) continue;
|
||||
if (task.type === 'DOWNLOAD_CHAPTER') {
|
||||
if (downloads >= MAX_CONCURRENT_DOWNLOADS) continue;
|
||||
downloads += 1;
|
||||
}
|
||||
busyQueues.add(task.queueName);
|
||||
start(task.id);
|
||||
}
|
||||
} finally {
|
||||
pumping = false;
|
||||
}
|
||||
};
|
||||
|
||||
const start = (taskId: string) => {
|
||||
const claimed = update(taskId, task =>
|
||||
task.state === 'queued' ? { ...task, state: 'running' } : task,
|
||||
);
|
||||
if (claimed?.state !== 'running') return;
|
||||
|
||||
let result: Result | undefined;
|
||||
const settled = new Promise<void>(resolve => {
|
||||
executing.set(taskId, value => {
|
||||
result ??= value;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
// Required lazily: the runner imports this module.
|
||||
const { runHeadlessBackgroundTask } =
|
||||
require('../../../src/services/backgroundTasks/headlessTask') as typeof import('../../../src/services/backgroundTasks/headlessTask');
|
||||
const run = runHeadlessBackgroundTask({ taskId }).catch((error: unknown) => {
|
||||
result ??= {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
retry: false,
|
||||
};
|
||||
});
|
||||
|
||||
Promise.race([settled, run])
|
||||
.then(() => run)
|
||||
.finally(() => {
|
||||
executing.delete(taskId);
|
||||
finish(taskId, result);
|
||||
pump();
|
||||
});
|
||||
};
|
||||
|
||||
const finish = (taskId: string, result: Result | undefined) => {
|
||||
update(taskId, task => {
|
||||
if (task.state === 'cancelled') return null;
|
||||
if (task.state !== 'running') return task; // paused meanwhile
|
||||
if (!result) {
|
||||
return {
|
||||
...task,
|
||||
state: 'failed',
|
||||
progressText: 'The task ended without a result',
|
||||
};
|
||||
}
|
||||
if (result.ok) {
|
||||
return { ...task, state: 'succeeded', progress: undefined };
|
||||
}
|
||||
if (result.retry) {
|
||||
return {
|
||||
...task,
|
||||
state: 'queued',
|
||||
attempt: task.attempt + 1,
|
||||
retryAt: Date.now() + RETRY_DELAY_MS,
|
||||
};
|
||||
}
|
||||
return { ...task, state: 'failed', progressText: result.error };
|
||||
});
|
||||
const after = load().find(task => task.id === taskId);
|
||||
if (after?.retryAt) setTimeout(pump, RETRY_DELAY_MS + 50);
|
||||
};
|
||||
|
||||
// A task that was running when the app was closed is queued again.
|
||||
save(
|
||||
load().map(task =>
|
||||
task.state === 'running' ? { ...task, state: 'queued' } : task,
|
||||
),
|
||||
);
|
||||
setTimeout(pump, 1500);
|
||||
|
||||
// ---- module contract ---------------------------------------------------------
|
||||
|
||||
const NativeBackgroundTasks = {
|
||||
async getTasks(): Promise<NativeBackgroundTaskSummary[]> {
|
||||
const now = Date.now();
|
||||
const kept = load().filter(
|
||||
task =>
|
||||
ACTIVE.has(task.state) || now - task.updatedAt < TERMINAL_RETENTION_MS,
|
||||
);
|
||||
save(kept);
|
||||
return kept.filter(task => ACTIVE.has(task.state)).map(summary);
|
||||
},
|
||||
|
||||
async getTask(taskId: string): Promise<NativeBackgroundTaskRecord | null> {
|
||||
const task = load().find(item => item.id === taskId);
|
||||
return task ? record(task) : null;
|
||||
},
|
||||
|
||||
async enqueue(
|
||||
type: string,
|
||||
payload: string,
|
||||
title: string,
|
||||
description: string,
|
||||
allowsDuplicates: boolean,
|
||||
queueName: string,
|
||||
): Promise<string> {
|
||||
const tasks = load();
|
||||
if (!allowsDuplicates) {
|
||||
const active = tasks.find(t => t.type === type && ACTIVE.has(t.state));
|
||||
if (active) return active.id;
|
||||
}
|
||||
const now = Date.now();
|
||||
const task: Task = {
|
||||
id: makeId(),
|
||||
type,
|
||||
payload,
|
||||
title,
|
||||
description,
|
||||
queueName,
|
||||
state: 'queued',
|
||||
attempt: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
save([...tasks, task]);
|
||||
pump();
|
||||
return task.id;
|
||||
},
|
||||
|
||||
async pause(taskId: string): Promise<void> {
|
||||
requireTask(taskId);
|
||||
const paused = update(taskId, task =>
|
||||
task.state === 'queued' || task.state === 'running'
|
||||
? { ...task, state: 'paused' }
|
||||
: task,
|
||||
);
|
||||
if (paused?.state === 'paused') interrupt(taskId, 'pause');
|
||||
},
|
||||
|
||||
async resume(taskId: string): Promise<void> {
|
||||
requireTask(taskId);
|
||||
if (executing.has(taskId)) {
|
||||
throw new Error('Task is still pausing; try resuming again shortly');
|
||||
}
|
||||
update(taskId, task =>
|
||||
task.state === 'paused'
|
||||
? { ...task, state: 'queued', retryAt: undefined }
|
||||
: task,
|
||||
);
|
||||
pump();
|
||||
},
|
||||
|
||||
async cancel(taskId: string): Promise<void> {
|
||||
const task = requireTask(taskId);
|
||||
const running = task.state === 'running' || executing.has(taskId);
|
||||
if (running) {
|
||||
update(taskId, item => ({ ...item, state: 'cancelled' }));
|
||||
interrupt(taskId, 'cancel');
|
||||
} else {
|
||||
update(taskId, () => null);
|
||||
}
|
||||
},
|
||||
|
||||
async updateProgress(
|
||||
taskId: string,
|
||||
progress: number,
|
||||
progressText: string,
|
||||
): Promise<void> {
|
||||
update(taskId, task => ({
|
||||
...task,
|
||||
progress: progress < 0 ? undefined : progress,
|
||||
progressText: progressText || undefined,
|
||||
}));
|
||||
},
|
||||
|
||||
async updateCheckpoint(taskId: string, checkpoint: string): Promise<void> {
|
||||
requireTask(taskId);
|
||||
update(taskId, task => ({ ...task, checkpoint }));
|
||||
},
|
||||
|
||||
async complete(taskId: string, completionText: string): Promise<void> {
|
||||
update(taskId, task => ({
|
||||
...task,
|
||||
checkpoint: undefined,
|
||||
progress: undefined,
|
||||
progressText: completionText,
|
||||
}));
|
||||
settle(taskId, { ok: true });
|
||||
},
|
||||
|
||||
async fail(
|
||||
taskId: string,
|
||||
error: string,
|
||||
shouldRetry: boolean,
|
||||
): Promise<void> {
|
||||
update(taskId, task => ({
|
||||
...task,
|
||||
progress: undefined,
|
||||
progressText: error,
|
||||
}));
|
||||
settle(taskId, { ok: false, error, retry: shouldRetry });
|
||||
},
|
||||
|
||||
// No reliable periodic background execution on iOS: library updates and
|
||||
// automatic backups run when started from the app.
|
||||
async scheduleLibraryUpdates(): Promise<void> {},
|
||||
async cancelLibraryUpdates(): Promise<void> {},
|
||||
async scheduleAutomaticBackups(): Promise<void> {},
|
||||
async cancelAutomaticBackups(): Promise<void> {},
|
||||
};
|
||||
|
||||
export default NativeBackgroundTasks;
|
||||
@@ -96,9 +96,44 @@ public class NativeFileModule: Module {
|
||||
}
|
||||
}
|
||||
|
||||
// As the Android module: GET, or POST with the body for any other method; the file
|
||||
// is written only for a 2xx response. URLSession undoes gzip Content-Encoding itself.
|
||||
AsyncFunction("downloadFile") { (url: String, destPath: String, method: String, headers: [String: String], body: String?, promise: Promise) in
|
||||
// Stub — download implementation not ported for iOS
|
||||
promise.reject("NOT_IMPLEMENTED", "downloadFile is not implemented on iOS")
|
||||
guard let target = URL(string: url) else {
|
||||
promise.reject("DOWNLOAD_FAILED", "Invalid URL: \(url)")
|
||||
return
|
||||
}
|
||||
var request = URLRequest(url: target)
|
||||
if method.lowercased() != "get", let body = body {
|
||||
request.httpMethod = "POST"
|
||||
request.httpBody = body.data(using: .utf8)
|
||||
}
|
||||
for (key, value) in headers {
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
URLSession.shared.downloadTask(with: request) { location, response, error in
|
||||
if let error = error {
|
||||
promise.reject("DOWNLOAD_FAILED", error.localizedDescription)
|
||||
return
|
||||
}
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||
guard (200..<300).contains(status), let location = location else {
|
||||
promise.reject("DOWNLOAD_FAILED", "Failed to download: \(status)")
|
||||
return
|
||||
}
|
||||
do {
|
||||
let destination = URL(fileURLWithPath: destPath)
|
||||
let files = FileManager.default
|
||||
try files.createDirectory(at: destination.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
if files.fileExists(atPath: destPath) {
|
||||
try files.removeItem(at: destination)
|
||||
}
|
||||
try files.moveItem(at: location, to: destination)
|
||||
promise.resolve(nil)
|
||||
} catch {
|
||||
promise.reject("DOWNLOAD_FAILED", error.localizedDescription)
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
Constant("DocumentDirectoryPath") {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// "Share to LNReader" is Android-only (an intent filter); on iOS nothing is ever shared in.
|
||||
import { EventEmitter } from 'expo-modules-core';
|
||||
|
||||
export type NativeShareReceiverEvents = {
|
||||
SharedText: (payload: { text: string }) => void;
|
||||
};
|
||||
|
||||
class NativeShareReceiverStub extends EventEmitter<NativeShareReceiverEvents> {
|
||||
getInitialSharedText(): string | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default new NativeShareReceiverStub();
|
||||
@@ -6,5 +6,11 @@ public class NativeVolumeButtonListenerModule: Module {
|
||||
Name("NativeVolumeButtonListener")
|
||||
|
||||
Events("VolumeUp", "VolumeDown")
|
||||
|
||||
// The reader calls this on every chapter. Page turning with the volume buttons is
|
||||
// Android-only (iOS gives apps no volume-key events), so it only records the choice.
|
||||
Function("setActive") { (active: Bool) in
|
||||
_ = active
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -33,7 +33,8 @@
|
||||
"check": "pnpm run format:check && pnpm run lint && pnpm run type-check",
|
||||
"prepare": "husky install",
|
||||
"nix:shell": "nix develop --extra-experimental-features nix-command --extra-experimental-features flakes",
|
||||
"nix:emulator": "nix develop .#emulator --extra-experimental-features nix-command --extra-experimental-features flakes"
|
||||
"nix:emulator": "nix develop .#emulator --extra-experimental-features nix-command --extra-experimental-features flakes",
|
||||
"postinstall": "node scripts/embed-reader-assets.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gorhom/bottom-sheet": "^5.2.14",
|
||||
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build an unsigned LNReader .ipa for sideloading. SideStore (or AltStore) signs it
|
||||
# on install with a free Apple ID; see docs/ios-sidestore.md.
|
||||
#
|
||||
# Runs on macOS with Xcode (and its command line tools), Node >= 22.11, pnpm and
|
||||
# CocoaPods. Output: build/LNReader-unsigned.ipa
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
[[ "$(uname)" == "Darwin" ]] || { echo "build-ios-unsigned: needs macOS with Xcode" >&2; exit 1; }
|
||||
command -v xcodebuild >/dev/null || { echo "build-ios-unsigned: install Xcode first" >&2; exit 1; }
|
||||
command -v pod >/dev/null || { echo "build-ios-unsigned: install CocoaPods (brew install cocoapods)" >&2; exit 1; }
|
||||
|
||||
pnpm install --frozen-lockfile # postinstall embeds the reader assets for iOS
|
||||
pnpm run generate:env:release
|
||||
pnpm exec expo prebuild --platform ios --clean # also runs pod install
|
||||
|
||||
xcodebuild \
|
||||
-workspace ios/LNReader.xcworkspace \
|
||||
-scheme LNReader \
|
||||
-configuration Release \
|
||||
-sdk iphoneos \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-derivedDataPath build/ios \
|
||||
CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" \
|
||||
build
|
||||
|
||||
app=build/ios/Build/Products/Release-iphoneos/LNReader.app
|
||||
[[ -d "$app" ]] || { echo "build-ios-unsigned: $app was not produced" >&2; exit 1; }
|
||||
rm -rf build/ipa build/LNReader-unsigned.ipa
|
||||
mkdir -p build/ipa/Payload
|
||||
cp -R "$app" build/ipa/Payload/
|
||||
(cd build/ipa && zip -qry ../LNReader-unsigned.ipa Payload)
|
||||
rm -rf build/ipa
|
||||
echo "Built build/LNReader-unsigned.ipa ($(du -h build/LNReader-unsigned.ipa | cut -f1))"
|
||||
@@ -0,0 +1,21 @@
|
||||
// Embeds assets/reader (css, js, fonts) into a TS module for iOS, where WKWebView cannot
|
||||
// load file:// subresources into a chapter whose base URL is the source's site.
|
||||
// Run by `pnpm postinstall` and the iOS build script; the output is not committed.
|
||||
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, parse } from 'node:path';
|
||||
|
||||
const root = join(import.meta.dirname, '..', 'assets', 'reader');
|
||||
const read = (dir, encoding) =>
|
||||
Object.fromEntries(
|
||||
readdirSync(join(root, dir)).sort().map(file => [parse(file).name, readFileSync(join(root, dir, file), encoding)]),
|
||||
);
|
||||
|
||||
const out = join(import.meta.dirname, '..', 'src', 'utils', 'readerAssets', 'embedded.generated.ts');
|
||||
writeFileSync(
|
||||
out,
|
||||
'// Generated by scripts/embed-reader-assets.mjs from assets/reader. Do not edit.\n' +
|
||||
`export const css: Record<string, string> = ${JSON.stringify(read('css', 'utf8'))};\n` +
|
||||
`export const js: Record<string, string> = ${JSON.stringify(read('js', 'utf8'))};\n` +
|
||||
`export const fonts: Record<string, string> = ${JSON.stringify(read('fonts', 'base64'))};\n`,
|
||||
);
|
||||
console.log(`reader assets embedded: ${out}`);
|
||||
@@ -11,6 +11,7 @@ import { open } from '@op-engineering/op-sqlite';
|
||||
import {
|
||||
createCategoryDefaultQuery,
|
||||
repairDefaultCategoryNameQuery,
|
||||
createDefaultRepositoryQuery,
|
||||
} from './queryStrings/populate';
|
||||
import {
|
||||
createCategoryTriggerQuery,
|
||||
@@ -336,6 +337,7 @@ const populateDatabase = (executor: SqlExecutor) => {
|
||||
console.log('Populating database');
|
||||
executor.executeSync(createCategoryDefaultQuery);
|
||||
executor.executeSync(repairDefaultCategoryNameQuery);
|
||||
executor.executeSync(createDefaultRepositoryQuery);
|
||||
};
|
||||
|
||||
const createDbTriggers = (executor: SqlExecutor) => {
|
||||
|
||||
@@ -785,7 +785,9 @@ export const getPrevChapter = async (
|
||||
.orderBy(
|
||||
desc(castInt(chapterSchema.page)),
|
||||
desc(castInt(chapterSchema.position)),
|
||||
desc(chapterSchema.id),
|
||||
)
|
||||
.limit(1)
|
||||
.get();
|
||||
};
|
||||
|
||||
@@ -814,7 +816,9 @@ export const getNextChapter = async (
|
||||
.orderBy(
|
||||
asc(castInt(chapterSchema.page)),
|
||||
asc(castInt(chapterSchema.position)),
|
||||
asc(chapterSchema.id),
|
||||
)
|
||||
.limit(1)
|
||||
.get();
|
||||
};
|
||||
|
||||
|
||||
@@ -1008,6 +1008,114 @@ describe('ChapterQueries', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('chapter neighbors', () => {
|
||||
it('limits rows transferred by both neighbor lookups to one', async () => {
|
||||
const testDb = getTestDb();
|
||||
const novelId = await insertTestNovel(testDb);
|
||||
for (let position = 0; position < 5; position++) {
|
||||
await insertTestChapter(testDb, novelId, { position });
|
||||
}
|
||||
const client = testDb.sqlite as typeof testDb.sqlite & {
|
||||
executeRawAsync: typeof testDb.sqlite.executeRaw;
|
||||
};
|
||||
const executeRaw = jest.spyOn(client, 'executeRawAsync');
|
||||
try {
|
||||
expect((await getNextChapter(novelId, 0, '1'))?.position).toBe(1);
|
||||
expect((await getPrevChapter(novelId, 4, '1'))?.position).toBe(3);
|
||||
expect(executeRaw).toHaveBeenCalledTimes(2);
|
||||
for (const call of executeRaw.mock.calls) {
|
||||
expect(call[0]).toMatch(/limit \?/i);
|
||||
expect(call[1]?.at(-1)).toBe(1);
|
||||
}
|
||||
for (const result of executeRaw.mock.results) {
|
||||
expect(await result.value).toHaveLength(1);
|
||||
}
|
||||
} finally {
|
||||
executeRaw.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns no neighbor before the first or after the last chapter', async () => {
|
||||
const testDb = getTestDb();
|
||||
const novelId = await insertTestNovel(testDb);
|
||||
await insertTestChapter(testDb, novelId, { page: '1', position: 0 });
|
||||
await insertTestChapter(testDb, novelId, { page: '2', position: 1 });
|
||||
|
||||
expect(await getPrevChapter(novelId, 0, '1')).toBeUndefined();
|
||||
expect(await getNextChapter(novelId, 1, '2')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('breaks duplicate position ties by id in the navigation direction', async () => {
|
||||
const testDb = getTestDb();
|
||||
const novelId = await insertTestNovel(testDb);
|
||||
const firstId = await insertTestChapter(testDb, novelId, { position: 1 });
|
||||
const secondId = await insertTestChapter(testDb, novelId, {
|
||||
position: 1,
|
||||
});
|
||||
|
||||
expect((await getNextChapter(novelId, 0, '1'))?.id).toBe(firstId);
|
||||
expect((await getPrevChapter(novelId, 2, '1'))?.id).toBe(secondId);
|
||||
expect(await getNextChapter(novelId, 1, '1')).toBeUndefined();
|
||||
expect(await getPrevChapter(novelId, 1, '1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves numeric-looking page comparisons and integer page ordering', async () => {
|
||||
const testDb = getTestDb();
|
||||
const novelId = await insertTestNovel(testDb);
|
||||
const page1 = await insertTestChapter(testDb, novelId, { page: '1' });
|
||||
const page02 = await insertTestChapter(testDb, novelId, { page: '02' });
|
||||
const page2 = await insertTestChapter(testDb, novelId, { page: '2' });
|
||||
const page10 = await insertTestChapter(testDb, novelId, { page: '10' });
|
||||
|
||||
// The cast parameter gives comparisons numeric affinity: '02' and '2'
|
||||
// compare equally, and page 10 sorts after page 2.
|
||||
expect((await getPrevChapter(novelId, 0, '2'))?.id).toBe(page1);
|
||||
expect((await getPrevChapter(novelId, 0, '10'))?.id).toBe(page2);
|
||||
expect((await getNextChapter(novelId, 0, '02'))?.id).toBe(page10);
|
||||
expect((await getPrevChapter(novelId, 0, '02'))?.id).toBe(page1);
|
||||
expect((await getNextChapter(novelId, 0, '1'))?.id).toBe(page02);
|
||||
});
|
||||
|
||||
it('preserves integer-cast behavior for source-defined page labels', async () => {
|
||||
const testDb = getTestDb();
|
||||
const novelId = await insertTestNovel(testDb);
|
||||
await insertTestChapter(testDb, novelId, {
|
||||
page: 'Volume A',
|
||||
position: 2,
|
||||
});
|
||||
const chapterId = await insertTestChapter(testDb, novelId, {
|
||||
page: 'Volume B',
|
||||
position: 0,
|
||||
});
|
||||
|
||||
// Labels cast to zero, so existing eligibility treats both as later
|
||||
// pages and sorting resolves their equal page casts by position.
|
||||
expect(await getPrevChapter(novelId, 1, 'Volume A')).toBeUndefined();
|
||||
expect((await getNextChapter(novelId, 1, 'Volume A'))?.id).toBe(
|
||||
chapterId,
|
||||
);
|
||||
});
|
||||
|
||||
it('skips excluded chapters at the previous page boundary', async () => {
|
||||
const testDb = getTestDb();
|
||||
const novelId = await insertTestNovel(testDb);
|
||||
const chapterId = await insertTestChapter(testDb, novelId, {
|
||||
page: '1',
|
||||
position: 0,
|
||||
scanlator: null,
|
||||
});
|
||||
await insertTestChapter(testDb, novelId, {
|
||||
page: '1',
|
||||
position: 1,
|
||||
scanlator: 'Excluded',
|
||||
});
|
||||
|
||||
expect((await getPrevChapter(novelId, 0, '2', ['Excluded']))?.id).toBe(
|
||||
chapterId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPrevChapter', () => {
|
||||
it('should return previous chapter in same page', async () => {
|
||||
const testDb = getTestDb();
|
||||
|
||||
@@ -14,3 +14,14 @@ UPDATE Category
|
||||
SET name = '${escapeSqlString(getString('categories.default'))}'
|
||||
WHERE id = 1 AND TRIM(name) = ''
|
||||
`;
|
||||
|
||||
// open-lab fork: a fresh install starts with the open-lab plugin repository (its
|
||||
// Kavita plugin reads the self-hosted library). Only when no repository exists yet.
|
||||
export const OPEN_LAB_PLUGIN_REPOSITORY =
|
||||
'https://git.marcospaulo.dev.br/masi/lnreader-plugins/raw/branch/plugins/v3.0.0/.dist/plugins.min.json';
|
||||
|
||||
export const createDefaultRepositoryQuery = `
|
||||
INSERT INTO Repository (url)
|
||||
SELECT '${OPEN_LAB_PLUGIN_REPOSITORY}'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM Repository)
|
||||
`;
|
||||
|
||||
@@ -66,6 +66,8 @@ export const initialChapterGeneralSettings = {
|
||||
bionicReading: false,
|
||||
tapToScroll: false,
|
||||
TTSEnable: true,
|
||||
pageReaderInvertVolumeButtons: false,
|
||||
pageReaderDisableAnimation: false,
|
||||
};
|
||||
|
||||
export const initialChapterReaderSettings = {
|
||||
|
||||
@@ -194,6 +194,8 @@ export interface ChapterGeneralSettings {
|
||||
bionicReading: boolean;
|
||||
tapToScroll: boolean;
|
||||
TTSEnable: boolean;
|
||||
pageReaderInvertVolumeButtons: boolean;
|
||||
pageReaderDisableAnimation: boolean;
|
||||
}
|
||||
|
||||
export interface ReaderTheme {
|
||||
@@ -319,6 +321,8 @@ export const initialChapterGeneralSettings: ChapterGeneralSettings = {
|
||||
bionicReading: false,
|
||||
tapToScroll: false,
|
||||
TTSEnable: true,
|
||||
pageReaderInvertVolumeButtons: false,
|
||||
pageReaderDisableAnimation: false,
|
||||
};
|
||||
|
||||
export const initialChapterReaderSettings: ChapterReaderSettings = {
|
||||
|
||||
@@ -668,6 +668,10 @@
|
||||
"padding": "Padding",
|
||||
"pageReader": "Paged reading (Experimental)",
|
||||
"pageReaderDescription": "Split the chapter into pages instead of scrolling. Experimental — may break formatting in some novels",
|
||||
"invertVolumeButtons": "Invert volume buttons",
|
||||
"invertVolumeButtonsDescription": "Swap volume up and down buttons for previous and next page",
|
||||
"disablePageTransitions": "Disable page transition animation",
|
||||
"disablePageTransitionsDescription": "Turn pages instantly without slide animation",
|
||||
"removeExtraSpacing": "Remove extra spacing",
|
||||
"removeExtraSpacingDescription": "Collapse extra blank lines between paragraphs",
|
||||
"scrollAmount": "Scroll amount (screen height by default)",
|
||||
|
||||
@@ -662,8 +662,12 @@
|
||||
"lineHeight": "Line height",
|
||||
"navigation": "Navigation",
|
||||
"padding": "Khoảng đệm",
|
||||
"pageReader": "Paged reading (Experimental)",
|
||||
"pageReaderDescription": "Split the chapter into pages instead of scrolling. Experimental — may break formatting in some novels",
|
||||
"pageReader": "Đọc phân trang (Thử nghiệm)",
|
||||
"pageReaderDescription": "Phân chương ra thành trang thay vì scroll. Thử nghiệm - có thể làm hỏng format một số tiểu thuyết",
|
||||
"invertVolumeButtons": "Đảo nút âm lượng",
|
||||
"invertVolumeButtonsDescription": "Đảo nút tăng và giảm âm lượng cho trang trước và sau",
|
||||
"disablePageTransitions": "Tắt hoạt hình chuyển trạng thái trang",
|
||||
"disablePageTransitionsDescription": "Chuyển trang ngay lập tức mà không dùng hoạt hình trượt",
|
||||
"removeExtraSpacing": "Remove extra spacing",
|
||||
"removeExtraSpacingDescription": "Collapse extra blank lines between paragraphs",
|
||||
"scrollAmount": "Scroll amount (screen height by default)",
|
||||
|
||||
@@ -566,6 +566,10 @@ export interface StringMap {
|
||||
'readerScreen.bottomSheet.padding': 'string';
|
||||
'readerScreen.bottomSheet.pageReader': 'string';
|
||||
'readerScreen.bottomSheet.pageReaderDescription': 'string';
|
||||
'readerScreen.bottomSheet.invertVolumeButtons': 'string';
|
||||
'readerScreen.bottomSheet.invertVolumeButtonsDescription': 'string';
|
||||
'readerScreen.bottomSheet.disablePageTransitions': 'string';
|
||||
'readerScreen.bottomSheet.disablePageTransitionsDescription': 'string';
|
||||
'readerScreen.bottomSheet.removeExtraSpacing': 'string';
|
||||
'readerScreen.bottomSheet.removeExtraSpacingDescription': 'string';
|
||||
'readerScreen.bottomSheet.scrollAmount': 'string';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { ScrollView, StyleSheet, ToastAndroid, View } from 'react-native';
|
||||
import { ScrollView, StyleSheet, View } from 'react-native';
|
||||
import { showToast } from '@utils/showToast';
|
||||
import { Portal } from 'react-native-paper';
|
||||
|
||||
import BottomSheet from '@components/BottomSheet/BottomSheet';
|
||||
@@ -105,13 +106,13 @@ const TrackSheet: React.FC<TrackSheetProps> = ({ bottomSheetRef, novel }) => {
|
||||
if (!activeTracker) return;
|
||||
|
||||
if (!newChapters) {
|
||||
ToastAndroid.show('Enter a valid number', ToastAndroid.SHORT);
|
||||
showToast('Enter a valid number');
|
||||
return;
|
||||
}
|
||||
|
||||
const newProgress = Number(newChapters);
|
||||
if (isNaN(newProgress)) {
|
||||
ToastAndroid.show('Enter a valid number', ToastAndroid.SHORT);
|
||||
showToast('Enter a valid number');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -138,6 +138,19 @@ const navigationPreferences: GeneralPreference[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const pagedModePreferences: GeneralPreference[] = [
|
||||
{
|
||||
key: 'pageReaderInvertVolumeButtons',
|
||||
label: 'invertVolumeButtons',
|
||||
description: 'invertVolumeButtonsDescription',
|
||||
},
|
||||
{
|
||||
key: 'pageReaderDisableAnimation',
|
||||
label: 'disablePageTransitions',
|
||||
description: 'disablePageTransitionsDescription',
|
||||
},
|
||||
];
|
||||
|
||||
const GeneralTab: React.FC = React.memo(() => {
|
||||
const theme = useTheme();
|
||||
const { setChapterGeneralSettings, ...settings } =
|
||||
@@ -182,7 +195,18 @@ const GeneralTab: React.FC = React.memo(() => {
|
||||
<List.SubHeader theme={theme}>
|
||||
{getString('readerScreen.bottomSheet.navigation')}
|
||||
</List.SubHeader>
|
||||
{navigationPreferences.map(renderPreference)}
|
||||
{navigationPreferences.map(item => {
|
||||
if (item.key === 'pageReader') {
|
||||
return (
|
||||
<React.Fragment key={item.key}>
|
||||
{renderPreference(item)}
|
||||
{settings.pageReader &&
|
||||
pagedModePreferences.map(renderPreference)}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
return renderPreference(item);
|
||||
})}
|
||||
</BottomSheetScrollView>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,8 +3,7 @@ import {
|
||||
NativeEventEmitter,
|
||||
NativeModules,
|
||||
StatusBar,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
StyleSheet, Platform } from 'react-native';
|
||||
import WebView from 'react-native-webview';
|
||||
import * as Linking from 'expo-linking';
|
||||
import color from 'color';
|
||||
@@ -23,7 +22,7 @@ import {
|
||||
initialChapterReaderSettings,
|
||||
} from '@hooks/persisted/useSettings';
|
||||
import { getBatteryLevel } from 'react-native-device-info';
|
||||
import { PLUGIN_STORAGE } from '@utils/Storages';
|
||||
import { PLUGIN_STORAGE, ROOT_STORAGE } from '@utils/Storages';
|
||||
import { useChapterContext } from '../ChapterContext';
|
||||
import { ReaderSearchResult } from '../types';
|
||||
import { useTtsSession } from '../hooks/useTtsSession';
|
||||
@@ -37,6 +36,7 @@ import {
|
||||
isChapterRefreshUrl,
|
||||
isPluginIssueReportUrl,
|
||||
} from '../utils/sanitizeChapterText';
|
||||
import { fileScript, fileStyle, readerFontUrl, readerScript, readerStyle } from '@utils/readerAssets';
|
||||
|
||||
export type WebViewPostEvent = {
|
||||
type: string;
|
||||
@@ -120,9 +120,6 @@ const deviceInfoEmitter = new NativeEventEmitter(RNDeviceInfo);
|
||||
*/
|
||||
let lastKnownBatteryLevel = 0;
|
||||
|
||||
const assetsUriPrefix = __DEV__
|
||||
? 'http://localhost:8081/assets'
|
||||
: 'file:///android_asset';
|
||||
|
||||
const WebViewReader: React.FC<WebViewReaderProps> = ({
|
||||
onPress,
|
||||
@@ -164,8 +161,8 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
|
||||
|
||||
const [batteryLevel] = useState(lastKnownBatteryLevel);
|
||||
const plugin = getPlugin(novel?.pluginId);
|
||||
const pluginCustomJS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.js`;
|
||||
const pluginCustomCSS = `file://${PLUGIN_STORAGE}/${plugin?.id}/custom.css`;
|
||||
const pluginCustomJS = `${PLUGIN_STORAGE}/${plugin?.id}/custom.js`;
|
||||
const pluginCustomCSS = `${PLUGIN_STORAGE}/${plugin?.id}/custom.css`;
|
||||
const nextChapterScreenVisible = useRef<boolean>(false);
|
||||
const autoStartTTSRef = useRef<boolean>(false);
|
||||
const activeChapterIdRef = useRef(chapter.id);
|
||||
@@ -316,7 +313,13 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
const isNextChapterScreenVisible = nextChapterScreenVisible.current;
|
||||
return {
|
||||
baseUrl: !chapter.isDownloaded ? plugin?.site : undefined,
|
||||
// iOS: downloaded chapters reference their images by file path; WKWebView reads
|
||||
// them only from a file: base URL with file access enabled (see the props below).
|
||||
baseUrl: !chapter.isDownloaded
|
||||
? plugin?.site
|
||||
: Platform.OS === 'ios'
|
||||
? `file://${ROOT_STORAGE}/`
|
||||
: undefined,
|
||||
headers: plugin?.imageRequestInit?.headers,
|
||||
method: plugin?.imageRequestInit?.method,
|
||||
body: plugin?.imageRequestInit?.body,
|
||||
@@ -325,10 +328,10 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
|
||||
<html dir="${readerDir}">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
||||
<link rel="stylesheet" href="${assetsUriPrefix}/css/index.css">
|
||||
<link rel="stylesheet" href="${assetsUriPrefix}/css/pageReader.css">
|
||||
<link rel="stylesheet" href="${assetsUriPrefix}/css/toolWrapper.css">
|
||||
<link rel="stylesheet" href="${assetsUriPrefix}/css/tts.css">
|
||||
${readerStyle('index')}
|
||||
${readerStyle('pageReader')}
|
||||
${readerStyle('toolWrapper')}
|
||||
${readerStyle('tts')}
|
||||
<style>
|
||||
:root {
|
||||
--StatusBar-currentHeight: ${StatusBar.currentHeight}px;
|
||||
@@ -364,17 +367,17 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
|
||||
<style id="ln-font">
|
||||
@font-face {
|
||||
font-family: ${initialReaderSettings.fontFamily};
|
||||
src: url("file:///android_asset/fonts/${
|
||||
initialReaderSettings.fontFamily
|
||||
}.ttf");
|
||||
src: url("${readerFontUrl(initialReaderSettings.fontFamily)}");
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="${pluginCustomCSS}">
|
||||
${fileStyle(pluginCustomCSS)}
|
||||
<style id="ln-custom-css">${customCSS}</style>
|
||||
</head>
|
||||
<body class="${
|
||||
chapterGeneralSettings.pageReader ? 'page-reader' : ''
|
||||
}">
|
||||
} ${
|
||||
chapterGeneralSettings.pageReaderDisableAnimation ? 'no-animation' : ''
|
||||
}">
|
||||
<div class="transition-chapter" style="transform: ${
|
||||
isNextChapterScreenVisible
|
||||
? 'translateX(-100%)'
|
||||
@@ -412,15 +415,15 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
|
||||
},
|
||||
})}
|
||||
</script>
|
||||
<script src="${assetsUriPrefix}/js/polyfill-onscrollend.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/icons.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/van.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/text-vibe.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/core.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/search.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/index.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/textRemover.js"></script>
|
||||
<script src="${pluginCustomJS}"></script>
|
||||
${readerScript('polyfill-onscrollend')}
|
||||
${readerScript('icons')}
|
||||
${readerScript('van')}
|
||||
${readerScript('text-vibe')}
|
||||
${readerScript('core')}
|
||||
${readerScript('search')}
|
||||
${readerScript('index')}
|
||||
${readerScript('textRemover')}
|
||||
${fileScript(pluginCustomJS)}
|
||||
<script id="ln-custom-js">
|
||||
function fn(){
|
||||
let html = document.querySelector('#LNReader-chapter').innerHTML;
|
||||
@@ -437,8 +440,8 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
|
||||
chapter,
|
||||
chapterGeneralSettings,
|
||||
processedHtml,
|
||||
customJS,
|
||||
customCSS,
|
||||
customJS,
|
||||
customCSS,
|
||||
initialReaderSettings,
|
||||
novel,
|
||||
plugin,
|
||||
@@ -449,31 +452,33 @@ const WebViewReader: React.FC<WebViewReaderProps> = ({
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
onTouchStart={onTouchStart}
|
||||
style={{ backgroundColor: readerSettings.theme }}
|
||||
allowFileAccess={true}
|
||||
originWhitelist={['*']}
|
||||
scalesPageToFit={true}
|
||||
showsVerticalScrollIndicator={false}
|
||||
javaScriptEnabled={true}
|
||||
webviewDebuggingEnabled={__DEV__}
|
||||
onShouldStartLoadWithRequest={({ url }) => {
|
||||
if (isPluginIssueReportUrl(url)) {
|
||||
void Linking.openURL(url);
|
||||
return false;
|
||||
}
|
||||
if (isChapterRefreshUrl(url)) {
|
||||
refetch();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
onLoadEnd={() => {
|
||||
webViewRef.current?.injectJavaScript(
|
||||
`if (window.reader && window.reader.batteryLevel) {
|
||||
<>
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
onTouchStart={onTouchStart}
|
||||
style={{ backgroundColor: readerSettings.theme }}
|
||||
allowFileAccess={true}
|
||||
allowFileAccessFromFileURLs={Platform.OS === 'ios'}
|
||||
allowUniversalAccessFromFileURLs={Platform.OS === 'ios'}
|
||||
originWhitelist={['*']}
|
||||
scalesPageToFit={true}
|
||||
showsVerticalScrollIndicator={false}
|
||||
javaScriptEnabled={true}
|
||||
webviewDebuggingEnabled={__DEV__}
|
||||
onShouldStartLoadWithRequest={({ url }) => {
|
||||
if (isPluginIssueReportUrl(url)) {
|
||||
void Linking.openURL(url);
|
||||
return false;
|
||||
}
|
||||
if (isChapterRefreshUrl(url)) {
|
||||
refetch();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
onLoadEnd={() => {
|
||||
webViewRef.current?.injectJavaScript(
|
||||
`if (window.reader && window.reader.batteryLevel) {
|
||||
window.reader.batteryLevel.val = ${lastKnownBatteryLevel};
|
||||
}`,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react-native';
|
||||
import useChapter from '../useChapter';
|
||||
import NativeFile from '@modules/native-file';
|
||||
import NativeVolumeButtonListener from '@modules/native-volume-button-listener';
|
||||
|
||||
const mockUseNovelActions = jest.fn();
|
||||
const mockUseChapterGeneralSettings = jest.fn();
|
||||
@@ -172,6 +173,9 @@ describe('useChapter', () => {
|
||||
autoScrollOffset: 100,
|
||||
useVolumeButtons: false,
|
||||
volumeButtonsOffset: 100,
|
||||
pageReader: false,
|
||||
pageReaderInvertVolumeButtons: false,
|
||||
pageReaderDisableAnimation: false,
|
||||
});
|
||||
mockUseLibrarySettings.mockReturnValue({ incognitoMode: false });
|
||||
mockUseAppSettings.mockReturnValue({
|
||||
@@ -423,4 +427,108 @@ describe('useChapter', () => {
|
||||
expect(result.current.chapter.id).toBe(nextChapter.id);
|
||||
expect(result.current.chapterText).toBe('SANITIZED:next body');
|
||||
});
|
||||
|
||||
it('injects scripts supporting paged and normal modes when volume buttons are pressed', async () => {
|
||||
const store = createStore();
|
||||
mockUseNovelActions.mockReturnValue(store.state);
|
||||
const mockInjectJavaScript = jest.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useChapter(
|
||||
{ current: { injectJavaScript: mockInjectJavaScript } as any },
|
||||
initialChapter,
|
||||
novel,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.chapterContext.loading).toBe(false),
|
||||
);
|
||||
|
||||
const volumeUpCall = (
|
||||
NativeVolumeButtonListener.addListener as jest.Mock
|
||||
).mock.calls.find(([event]) => event === 'VolumeUp');
|
||||
const volumeDownCall = (
|
||||
NativeVolumeButtonListener.addListener as jest.Mock
|
||||
).mock.calls.find(([event]) => event === 'VolumeDown');
|
||||
|
||||
expect(volumeUpCall).toBeDefined();
|
||||
expect(volumeDownCall).toBeDefined();
|
||||
|
||||
// Trigger VolumeUp callback
|
||||
volumeUpCall[1]();
|
||||
expect(mockInjectJavaScript).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'window.pageReader.movePage((window.pageReader.page?.val ?? 0) - 1)',
|
||||
),
|
||||
);
|
||||
expect(mockInjectJavaScript).toHaveBeenCalledWith(
|
||||
expect.stringContaining('window.scrollBy'),
|
||||
);
|
||||
|
||||
// Trigger VolumeDown callback
|
||||
volumeDownCall[1]();
|
||||
expect(mockInjectJavaScript).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'window.pageReader.movePage((window.pageReader.page?.val ?? 0) + 1)',
|
||||
),
|
||||
);
|
||||
expect(mockInjectJavaScript).toHaveBeenCalledWith(
|
||||
expect.stringContaining('window.scrollBy'),
|
||||
);
|
||||
});
|
||||
|
||||
it('inverts volume button directions in paged mode when pageReaderInvertVolumeButtons is enabled', async () => {
|
||||
const store = createStore();
|
||||
mockUseNovelActions.mockReturnValue(store.state);
|
||||
mockUseChapterGeneralSettings.mockReturnValue({
|
||||
autoScroll: false,
|
||||
autoScrollInterval: 1,
|
||||
autoScrollOffset: 100,
|
||||
useVolumeButtons: false,
|
||||
volumeButtonsOffset: 100,
|
||||
pageReader: true,
|
||||
pageReaderInvertVolumeButtons: true,
|
||||
pageReaderDisableAnimation: false,
|
||||
});
|
||||
const mockInjectJavaScript = jest.fn();
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useChapter(
|
||||
{ current: { injectJavaScript: mockInjectJavaScript } as any },
|
||||
initialChapter,
|
||||
novel,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.chapterContext.loading).toBe(false),
|
||||
);
|
||||
|
||||
const volumeUpCall = (
|
||||
NativeVolumeButtonListener.addListener as jest.Mock
|
||||
).mock.calls.find(([event]) => event === 'VolumeUp');
|
||||
const volumeDownCall = (
|
||||
NativeVolumeButtonListener.addListener as jest.Mock
|
||||
).mock.calls.find(([event]) => event === 'VolumeDown');
|
||||
|
||||
expect(volumeUpCall).toBeDefined();
|
||||
expect(volumeDownCall).toBeDefined();
|
||||
|
||||
// VolumeUp callback should move +1 (forward) when inverted
|
||||
volumeUpCall[1]();
|
||||
expect(mockInjectJavaScript).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'window.pageReader.movePage((window.pageReader.page?.val ?? 0) + 1)',
|
||||
),
|
||||
);
|
||||
|
||||
// VolumeDown callback should move -1 (backward) when inverted
|
||||
volumeDownCall[1]();
|
||||
expect(mockInjectJavaScript).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'window.pageReader.movePage((window.pageReader.page?.val ?? 0) - 1)',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,6 +74,8 @@ export default function useChapter(
|
||||
autoScrollOffset,
|
||||
useVolumeButtons,
|
||||
volumeButtonsOffset,
|
||||
pageReader,
|
||||
pageReaderInvertVolumeButtons,
|
||||
} = useChapterGeneralSettings();
|
||||
const { incognitoMode } = useLibrarySettings();
|
||||
const { timeTrackingEnabled, inactivityTimeoutMs } = useAppSettings();
|
||||
@@ -124,15 +126,32 @@ export default function useChapter(
|
||||
Math.round(Dimensions.get('window').height * 0.75),
|
||||
);
|
||||
|
||||
const volumeUpDelta = pageReaderInvertVolumeButtons ? 1 : -1;
|
||||
const volumeDownDelta = pageReaderInvertVolumeButtons ? -1 : 1;
|
||||
|
||||
useEventListener(NativeVolumeButtonListener, 'VolumeUp', () => {
|
||||
webViewRef.current?.injectJavaScript(`(()=>{
|
||||
window.scrollBy({top: -${volumeButtonOffset}, behavior: 'smooth'})
|
||||
const isPaged = ${Boolean(pageReader)};
|
||||
if (isPaged && window.pageReader) {
|
||||
window.pageReader.movePage((window.pageReader.page?.val ?? 0) ${
|
||||
volumeUpDelta >= 0 ? '+' : '-'
|
||||
} 1);
|
||||
} else {
|
||||
window.scrollBy({top: -${volumeButtonOffset}, behavior: 'smooth'});
|
||||
}
|
||||
})()`);
|
||||
});
|
||||
|
||||
useEventListener(NativeVolumeButtonListener, 'VolumeDown', () => {
|
||||
webViewRef.current?.injectJavaScript(`(()=>{
|
||||
window.scrollBy({top: ${volumeButtonOffset}, behavior: 'smooth'})
|
||||
const isPaged = ${Boolean(pageReader)};
|
||||
if (isPaged && window.pageReader) {
|
||||
window.pageReader.movePage((window.pageReader.page?.val ?? 0) ${
|
||||
volumeDownDelta >= 0 ? '+' : '-'
|
||||
} 1);
|
||||
} else {
|
||||
window.scrollBy({top: ${volumeButtonOffset}, behavior: 'smooth'});
|
||||
}
|
||||
})()`);
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { getBatteryLevelSync } from 'react-native-device-info';
|
||||
import * as Speech from 'expo-speech';
|
||||
import { dummyHTML } from './dummies';
|
||||
import { useTheme } from '@hooks/persisted';
|
||||
import { readerFontUrl, readerScript, readerStyle } from '@utils/readerAssets';
|
||||
|
||||
type WebViewPostEvent = {
|
||||
type: string;
|
||||
@@ -34,9 +35,6 @@ const onLogMessage = (payload: { nativeEvent: { data: string } }) => {
|
||||
const { RNDeviceInfo } = NativeModules;
|
||||
const deviceInfoEmitter = new NativeEventEmitter(RNDeviceInfo);
|
||||
|
||||
const assetsUriPrefix = __DEV__
|
||||
? 'http://localhost:8081/assets'
|
||||
: 'file:///android_asset';
|
||||
|
||||
const novel = {
|
||||
'artist': null,
|
||||
@@ -178,13 +176,13 @@ const SettingsWebView = () => {
|
||||
}
|
||||
@font-face {
|
||||
font-family: ${settings.fontFamily};
|
||||
src: url("file:///android_asset/fonts/${settings.fontFamily}.ttf");
|
||||
src: url("${readerFontUrl(settings.fontFamily)}");
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="${assetsUriPrefix}/css/index.css">
|
||||
<link rel="stylesheet" href="${assetsUriPrefix}/css/pageReader.css">
|
||||
<link rel="stylesheet" href="${assetsUriPrefix}/css/toolWrapper.css">
|
||||
<link rel="stylesheet" href="${assetsUriPrefix}/css/tts.css">
|
||||
${readerStyle('index')}
|
||||
${readerStyle('pageReader')}
|
||||
${readerStyle('toolWrapper')}
|
||||
${readerStyle('tts')}
|
||||
<style>
|
||||
${customCSS}
|
||||
</style>
|
||||
@@ -281,11 +279,11 @@ const SettingsWebView = () => {
|
||||
},
|
||||
})}
|
||||
</script>
|
||||
<script src="${assetsUriPrefix}/js/icons.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/van.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/text-vibe.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/core.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/index.js"></script>
|
||||
${readerScript('icons')}
|
||||
${readerScript('van')}
|
||||
${readerScript('text-vibe')}
|
||||
${readerScript('core')}
|
||||
${readerScript('index')}
|
||||
<script>
|
||||
${customJS}
|
||||
async function fn(){
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { StatusBar, StyleSheet } from 'react-native';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import WebView from 'react-native-webview';
|
||||
|
||||
import {
|
||||
@@ -15,6 +15,7 @@ import { useBatteryLevel } from 'react-native-device-info';
|
||||
import { useTtsSession } from '@screens/reader/hooks/useTtsSession';
|
||||
import type { TtsSettings } from '@modules/nitro-tts';
|
||||
import { dummyHTML } from '@screens/settings/SettingsCustomCodeScreen/Components/dummies';
|
||||
import { readerFontUrl, readerScript, readerStyle } from '@utils/readerAssets';
|
||||
|
||||
type WebViewPostEvent = {
|
||||
type: string;
|
||||
@@ -89,12 +90,8 @@ const SettingsReaderWebView = ({
|
||||
updateSettings: updateTtsSettings,
|
||||
} = useTtsSession();
|
||||
|
||||
const assetsUriPrefix = useMemo(
|
||||
() => (__DEV__ ? 'http://localhost:8081/assets' : 'file:///android_asset'),
|
||||
[],
|
||||
);
|
||||
const webViewCSS = `
|
||||
<link rel="stylesheet" href="${assetsUriPrefix}/css/index.css">
|
||||
${readerStyle('index')}
|
||||
<style>
|
||||
:root {
|
||||
--StatusBar-currentHeight: ${StatusBar.currentHeight};
|
||||
@@ -122,9 +119,7 @@ const SettingsReaderWebView = ({
|
||||
|
||||
@font-face {
|
||||
font-family: ${readerSettings.fontFamily};
|
||||
src: url("file:///android_asset/fonts/${
|
||||
readerSettings.fontFamily
|
||||
}.ttf");
|
||||
src: url("${readerFontUrl(readerSettings.fontFamily)}");
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -267,11 +262,11 @@ const SettingsReaderWebView = ({
|
||||
},
|
||||
})}
|
||||
</script>
|
||||
<script src="${assetsUriPrefix}/js/icons.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/van.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/text-vibe.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/core.js"></script>
|
||||
<script src="${assetsUriPrefix}/js/index.js"></script>
|
||||
${readerScript('icons')}
|
||||
${readerScript('van')}
|
||||
${readerScript('text-vibe')}
|
||||
${readerScript('core')}
|
||||
${readerScript('index')}
|
||||
<script>
|
||||
${customJS ?? readerSettings.customJS}
|
||||
</script>
|
||||
|
||||
@@ -16,6 +16,8 @@ const NavigationTab: React.FC = () => {
|
||||
verticalSeekbar = true,
|
||||
swipeGestures = false,
|
||||
pageReader = false,
|
||||
pageReaderInvertVolumeButtons = false,
|
||||
pageReaderDisableAnimation = false,
|
||||
autoScroll = false,
|
||||
autoScrollInterval = 10,
|
||||
autoScrollOffset = null,
|
||||
@@ -119,6 +121,38 @@ const NavigationTab: React.FC = () => {
|
||||
onPress={() => setChapterGeneralSettings({ pageReader: !pageReader })}
|
||||
theme={theme}
|
||||
/>
|
||||
{pageReader && (
|
||||
<>
|
||||
<SettingSwitch
|
||||
label={getString('readerScreen.bottomSheet.invertVolumeButtons')}
|
||||
description={getString(
|
||||
'readerScreen.bottomSheet.invertVolumeButtonsDescription',
|
||||
)}
|
||||
value={pageReaderInvertVolumeButtons}
|
||||
onPress={() =>
|
||||
setChapterGeneralSettings({
|
||||
pageReaderInvertVolumeButtons: !pageReaderInvertVolumeButtons,
|
||||
})
|
||||
}
|
||||
theme={theme}
|
||||
/>
|
||||
<SettingSwitch
|
||||
label={getString(
|
||||
'readerScreen.bottomSheet.disablePageTransitions',
|
||||
)}
|
||||
description={getString(
|
||||
'readerScreen.bottomSheet.disablePageTransitionsDescription',
|
||||
)}
|
||||
value={pageReaderDisableAnimation}
|
||||
onPress={() =>
|
||||
setChapterGeneralSettings({
|
||||
pageReaderDisableAnimation: !pageReaderDisableAnimation,
|
||||
})
|
||||
}
|
||||
theme={theme}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
// The iOS JS implementation of the native background task module
|
||||
// (modules/native-background-tasks/src/NativeBackgroundTasksModule.ios.ts).
|
||||
import { DeviceEventEmitter } from 'react-native';
|
||||
|
||||
const mockStore = new Map<string, unknown>();
|
||||
jest.mock('@utils/mmkv/mmkv', () => ({
|
||||
getMMKVObject: (key: string) => mockStore.get(key),
|
||||
setMMKVObject: (key: string, value: unknown) => mockStore.set(key, value),
|
||||
}));
|
||||
|
||||
// The runner calls back into the module like backgroundTasks.run does.
|
||||
const mockRuns: string[] = [];
|
||||
let mockBehaviour: (taskId: string) => Promise<void> = async () => {};
|
||||
jest.mock('../headlessTask', () => ({
|
||||
runHeadlessBackgroundTask: ({ taskId }: { taskId: string }) => {
|
||||
mockRuns.push(taskId);
|
||||
return mockBehaviour(taskId);
|
||||
},
|
||||
}));
|
||||
|
||||
type Module =
|
||||
typeof import('../../../../modules/native-background-tasks/src/NativeBackgroundTasksModule.ios');
|
||||
|
||||
const load = (): Module['default'] => {
|
||||
let mod: Module['default'] | undefined;
|
||||
jest.isolateModules(() => {
|
||||
mod = (
|
||||
require('../../../../modules/native-background-tasks/src/NativeBackgroundTasksModule.ios') as Module
|
||||
).default;
|
||||
});
|
||||
return mod!;
|
||||
};
|
||||
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
const stateOf = async (tasks: Module['default'], id: string) =>
|
||||
(await tasks.getTask(id))?.state;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockStore.clear();
|
||||
mockRuns.length = 0;
|
||||
mockBehaviour = async () => {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
const enqueue = (
|
||||
tasks: Module['default'],
|
||||
type = 'IMPORT_EPUB',
|
||||
queue = 'q:a',
|
||||
duplicates = true,
|
||||
) => tasks.enqueue(type, '{}', 'title', '', duplicates, queue);
|
||||
|
||||
test('runs a queued task and records success', async () => {
|
||||
const tasks = load();
|
||||
mockBehaviour = async id => tasks.complete(id, 'done');
|
||||
const id = await enqueue(tasks);
|
||||
await flush();
|
||||
expect(mockRuns).toEqual([id]);
|
||||
expect(await stateOf(tasks, id)).toBe('succeeded');
|
||||
expect(await tasks.getTasks()).toEqual([]); // terminal tasks are not active
|
||||
});
|
||||
|
||||
test('a retryable failure queues the task again with the attempt counted', async () => {
|
||||
const tasks = load();
|
||||
let calls = 0;
|
||||
mockBehaviour = async id => {
|
||||
calls += 1;
|
||||
if (calls === 1) await tasks.fail(id, 'offline', true);
|
||||
else await tasks.complete(id, '');
|
||||
};
|
||||
const id = await enqueue(tasks);
|
||||
await flush();
|
||||
expect(await stateOf(tasks, id)).toBe('queued');
|
||||
expect((await tasks.getTask(id))?.attempt).toBe(1);
|
||||
jest.advanceTimersByTime(4000);
|
||||
await flush();
|
||||
expect(mockRuns).toHaveLength(1); // still waiting out the retry delay
|
||||
jest.advanceTimersByTime(1100);
|
||||
await flush();
|
||||
expect(await stateOf(tasks, id)).toBe('succeeded');
|
||||
});
|
||||
|
||||
test('a final failure and a runner that throws both end failed', async () => {
|
||||
const tasks = load();
|
||||
mockBehaviour = async id => tasks.fail(id, 'bad data', false);
|
||||
const a = await enqueue(tasks, 'IMPORT_EPUB', 'q:a');
|
||||
mockBehaviour = async () => {
|
||||
throw new Error('boom');
|
||||
};
|
||||
const b = await enqueue(tasks, 'EXPORT_EPUB', 'q:b');
|
||||
await flush();
|
||||
expect(await stateOf(tasks, a)).toBe('failed');
|
||||
expect(await stateOf(tasks, b)).toBe('failed');
|
||||
expect((await tasks.getTask(b))?.progressText).toBe('boom');
|
||||
});
|
||||
|
||||
test('one task at a time per queue, in order', async () => {
|
||||
const tasks = load();
|
||||
const finish: Record<string, () => void> = {};
|
||||
mockBehaviour = id =>
|
||||
new Promise<void>(resolve => {
|
||||
finish[id] = () => tasks.complete(id, '').then(resolve);
|
||||
});
|
||||
const first = await enqueue(tasks);
|
||||
const second = await enqueue(tasks);
|
||||
await flush();
|
||||
expect(mockRuns).toEqual([first]);
|
||||
expect(await stateOf(tasks, second)).toBe('queued');
|
||||
finish[first]();
|
||||
await flush();
|
||||
expect(mockRuns).toEqual([first, second]);
|
||||
});
|
||||
|
||||
test('at most three chapter downloads run at once', async () => {
|
||||
const tasks = load();
|
||||
mockBehaviour = () => new Promise<void>(() => {});
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await enqueue(tasks, 'DOWNLOAD_CHAPTER', `q:download:${i}`);
|
||||
}
|
||||
await flush();
|
||||
expect(mockRuns).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('without duplicates, an active task of the same type is reused', async () => {
|
||||
const tasks = load();
|
||||
mockBehaviour = () => new Promise<void>(() => {});
|
||||
const a = await enqueue(tasks, 'UPDATE_LIBRARY', 'q:u', false);
|
||||
const b = await enqueue(tasks, 'UPDATE_LIBRARY', 'q:u', false);
|
||||
expect(b).toBe(a);
|
||||
});
|
||||
|
||||
test('pause interrupts the runner and keeps the task paused; resume runs it again', async () => {
|
||||
const tasks = load();
|
||||
const events: unknown[] = [];
|
||||
const sub = DeviceEventEmitter.addListener('LNReaderTaskInterrupted', e =>
|
||||
events.push(e),
|
||||
);
|
||||
let stop: () => void = () => {};
|
||||
mockBehaviour = () =>
|
||||
new Promise<void>(resolve => {
|
||||
stop = resolve;
|
||||
});
|
||||
const id = await enqueue(tasks);
|
||||
await flush();
|
||||
await tasks.pause(id);
|
||||
expect(events).toEqual([{ taskId: id, action: 'pause' }]);
|
||||
await expect(tasks.resume(id)).rejects.toThrow('still pausing');
|
||||
stop(); // the runner notices the interruption and returns
|
||||
await flush();
|
||||
expect(await stateOf(tasks, id)).toBe('paused');
|
||||
mockBehaviour = async taskId => tasks.complete(taskId, '');
|
||||
await tasks.resume(id);
|
||||
await flush();
|
||||
expect(await stateOf(tasks, id)).toBe('succeeded');
|
||||
sub.remove();
|
||||
});
|
||||
|
||||
test('cancel removes a queued task and interrupts a running one', async () => {
|
||||
const tasks = load();
|
||||
let stop: () => void = () => {};
|
||||
mockBehaviour = () =>
|
||||
new Promise<void>(resolve => {
|
||||
stop = resolve;
|
||||
});
|
||||
const running = await enqueue(tasks, 'IMPORT_EPUB', 'q:a');
|
||||
const waiting = await enqueue(tasks, 'IMPORT_EPUB', 'q:a');
|
||||
await flush();
|
||||
await tasks.cancel(waiting);
|
||||
expect(await tasks.getTask(waiting)).toBeNull();
|
||||
await tasks.cancel(running);
|
||||
expect(await stateOf(tasks, running)).toBe('cancelled');
|
||||
stop();
|
||||
await flush();
|
||||
expect(await tasks.getTask(running)).toBeNull();
|
||||
});
|
||||
|
||||
test('a task running when the app closed is queued again and resumed', async () => {
|
||||
let tasks = load();
|
||||
mockBehaviour = () => new Promise<void>(() => {});
|
||||
const id = await enqueue(tasks);
|
||||
await flush();
|
||||
expect(await stateOf(tasks, id)).toBe('running');
|
||||
jest.advanceTimersByTime(1500); // this launch's start-up pump has run
|
||||
await flush();
|
||||
mockBehaviour = async taskId => tasks.complete(taskId, '');
|
||||
tasks = load(); // a new app launch reads the persisted queue
|
||||
expect(await stateOf(tasks, id)).toBe('queued');
|
||||
jest.advanceTimersByTime(1500);
|
||||
await flush();
|
||||
expect(await stateOf(tasks, id)).toBe('succeeded');
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
// iOS: WKWebView will not load file:// subresources into a page whose base URL is the
|
||||
// source's site, so the reader assets are inlined (embedded.generated.ts) and fonts
|
||||
// become data: URLs. Inline text must not close its own element.
|
||||
import NativeFile from '@modules/native-file';
|
||||
import { css, fonts, js } from './embedded.generated';
|
||||
|
||||
const style = (text: string) => `<style>${text.replace(/<\/style/gi, '<\\/style')}</style>`;
|
||||
const script = (text: string) => `<script>${text.replace(/<\/script/gi, '<\\/script')}</script>`;
|
||||
|
||||
export const readerStyle = (name: string) => style(css[name] ?? '');
|
||||
|
||||
export const readerScript = (name: string) => script(js[name] ?? '');
|
||||
|
||||
export const readerFontUrl = (font: string) =>
|
||||
fonts[font] ? `data:font/ttf;base64,${fonts[font]}` : '';
|
||||
|
||||
const readText = (path: string) => {
|
||||
try {
|
||||
// Synchronous on iOS (a Swift Function, not an AsyncFunction), despite the shared type.
|
||||
return NativeFile.readFile(path) as unknown as string;
|
||||
} catch {
|
||||
return ''; // the plugin ships no custom file
|
||||
}
|
||||
};
|
||||
|
||||
export const fileStyle = (path: string) => style(readText(path));
|
||||
|
||||
export const fileScript = (path: string) => script(readText(path));
|
||||
@@ -0,0 +1,20 @@
|
||||
// Reader HTML references to the bundled reader assets (assets/reader). Android loads
|
||||
// them from the APK's assets (copied by plugins/withReaderAssets.js); index.ios.ts
|
||||
// inlines them instead.
|
||||
const prefix = __DEV__ ? 'http://localhost:8081/assets' : 'file:///android_asset';
|
||||
|
||||
export const readerStyle = (name: string) =>
|
||||
`<link rel="stylesheet" href="${prefix}/css/${name}.css">`;
|
||||
|
||||
export const readerScript = (name: string) =>
|
||||
`<script src="${prefix}/js/${name}.js"></script>`;
|
||||
|
||||
export const readerFontUrl = (font: string) =>
|
||||
`file:///android_asset/fonts/${font}.ttf`;
|
||||
|
||||
// A plugin's own custom.css / custom.js in app storage.
|
||||
export const fileStyle = (path: string) =>
|
||||
`<link rel="stylesheet" href="file://${path}">`;
|
||||
|
||||
export const fileScript = (path: string) =>
|
||||
`<script src="file://${path}"></script>`;
|
||||
@@ -0,0 +1,7 @@
|
||||
// iOS has no system toast (ToastAndroid only warns there); messages are traced in development.
|
||||
export const showToast = (...message: string[]) => {
|
||||
if (__DEV__) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.trace('Toast: ', message);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user