258 lines
7.8 KiB
TypeScript
258 lines
7.8 KiB
TypeScript
import { DeviceEventEmitter } from 'react-native';
|
|
|
|
import NativeBackgroundTasks from '@modules/native-background-tasks';
|
|
import { getString } from '@i18n/translations';
|
|
import { askForPostNotificationsPermission } from '@utils/askForPostNoftificationsPermission';
|
|
import { getMMKVObject, setMMKVObject } from '@utils/mmkv/mmkv';
|
|
import { showToast } from '@utils/showToast';
|
|
import type {
|
|
BackgroundTask,
|
|
BackgroundTaskMetadata,
|
|
QueuedBackgroundTask,
|
|
} from './contracts';
|
|
import { executeBackgroundTask } from './executeTask';
|
|
import {
|
|
ACTIVE_BACKGROUND_TASK_STATES,
|
|
allowsDuplicateTask,
|
|
createBackgroundTaskMetadata,
|
|
fromNativeTaskRecord,
|
|
getBackgroundTaskQueueName,
|
|
willTaskWaitInQueue,
|
|
} from './taskDefinitions';
|
|
import { BACKGROUND_TASKS_STORE_KEY } from './constants';
|
|
|
|
const makeTemporaryId = () =>
|
|
`pending-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
|
|
export class BackgroundTaskQueue {
|
|
private interruptedTasks = new Map<string, 'pause' | 'cancel'>();
|
|
private notificationPermissionRequest?: Promise<boolean>;
|
|
|
|
constructor() {
|
|
DeviceEventEmitter.addListener(
|
|
'LNReaderTaskInterrupted',
|
|
({ taskId, action }: { taskId: string; action: 'pause' | 'cancel' }) => {
|
|
this.interruptedTasks.set(taskId, action);
|
|
},
|
|
);
|
|
}
|
|
|
|
get isRunning() {
|
|
return this.getSnapshot().some(
|
|
task => task.state === 'running' || task.state === 'queued',
|
|
);
|
|
}
|
|
|
|
getSnapshot() {
|
|
return (
|
|
getMMKVObject<QueuedBackgroundTask[]>(BACKGROUND_TASKS_STORE_KEY) || []
|
|
);
|
|
}
|
|
|
|
async refresh() {
|
|
const records = await NativeBackgroundTasks.getTasks();
|
|
const queue = records
|
|
.filter(record => ACTIVE_BACKGROUND_TASK_STATES.has(record.state))
|
|
.map(fromNativeTaskRecord);
|
|
this.store(queue);
|
|
return queue;
|
|
}
|
|
|
|
enqueue = (tasks: BackgroundTask | BackgroundTask[]) => {
|
|
for (const task of Array.isArray(tasks) ? tasks : [tasks]) {
|
|
this.enqueueOne(task, true).catch(() => undefined);
|
|
}
|
|
};
|
|
|
|
private enqueueSilently = (tasks: BackgroundTask | BackgroundTask[]) => {
|
|
for (const task of Array.isArray(tasks) ? tasks : [tasks]) {
|
|
this.enqueueOne(task, false).catch(() => undefined);
|
|
}
|
|
};
|
|
|
|
async pauseAll() {
|
|
const tasks = this.getSnapshot().filter(
|
|
task => task.state === 'running' || task.state === 'queued',
|
|
);
|
|
tasks.forEach(task => this.interruptedTasks.set(task.id, 'pause'));
|
|
await Promise.all(tasks.map(task => NativeBackgroundTasks.pause(task.id)));
|
|
await this.refresh();
|
|
}
|
|
|
|
async resumeAll() {
|
|
const tasks = this.getSnapshot().filter(task => task.state === 'paused');
|
|
const results = await Promise.allSettled(
|
|
tasks.map(task => NativeBackgroundTasks.resume(task.id)),
|
|
);
|
|
results.forEach((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
this.interruptedTasks.delete(tasks[index].id);
|
|
}
|
|
});
|
|
await this.refresh();
|
|
}
|
|
|
|
async cancelByType(name: BackgroundTask['name']) {
|
|
const tasks = this.getSnapshot().filter(task => task.task.name === name);
|
|
tasks.forEach(task => this.interruptedTasks.set(task.id, 'cancel'));
|
|
await Promise.all(tasks.map(task => NativeBackgroundTasks.cancel(task.id)));
|
|
await this.refresh();
|
|
}
|
|
|
|
async cancelAll() {
|
|
const tasks = this.getSnapshot();
|
|
tasks.forEach(task => this.interruptedTasks.set(task.id, 'cancel'));
|
|
await Promise.all(tasks.map(task => NativeBackgroundTasks.cancel(task.id)));
|
|
this.store([]);
|
|
}
|
|
|
|
async run(taskId: string, task: BackgroundTask, checkpoint?: string) {
|
|
const queue = this.getSnapshot();
|
|
if (!queue.some(item => item.id === taskId)) {
|
|
queue.push({
|
|
id: taskId,
|
|
task,
|
|
state: 'running',
|
|
meta: createBackgroundTaskMetadata(task, true),
|
|
});
|
|
this.store(queue);
|
|
}
|
|
|
|
try {
|
|
await executeBackgroundTask(
|
|
task,
|
|
transformer => this.updateProgress(taskId, transformer),
|
|
this.enqueueSilently,
|
|
{
|
|
checkpoint,
|
|
updateCheckpoint: value => {
|
|
if (this.interruptedTasks.get(taskId) === 'cancel') {
|
|
this.throwIfInterrupted(taskId);
|
|
}
|
|
return NativeBackgroundTasks.updateCheckpoint(taskId, value);
|
|
},
|
|
},
|
|
);
|
|
this.throwIfInterrupted(taskId);
|
|
const completedTask = this.getSnapshot().find(item => item.id === taskId);
|
|
await NativeBackgroundTasks.complete(
|
|
taskId,
|
|
completedTask?.meta.completionText ??
|
|
getString('notifications.taskCompleted'),
|
|
);
|
|
} catch (error) {
|
|
await NativeBackgroundTasks.fail(
|
|
taskId,
|
|
getString('notifications.taskFailed', {
|
|
error: error instanceof Error ? error.message : String(error),
|
|
}),
|
|
false,
|
|
);
|
|
if (!this.interruptedTasks.has(taskId)) {
|
|
throw error;
|
|
}
|
|
} finally {
|
|
this.finishLocalExecution(taskId);
|
|
}
|
|
}
|
|
|
|
private async enqueueOne(task: BackgroundTask, showQueuedToast: boolean) {
|
|
this.notificationPermissionRequest ??= askForPostNotificationsPermission();
|
|
await this.notificationPermissionRequest;
|
|
|
|
const current = this.getSnapshot();
|
|
if (
|
|
!allowsDuplicateTask(task) &&
|
|
current.some(item => item.task.name === task.name)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const pending: QueuedBackgroundTask = {
|
|
id: makeTemporaryId(),
|
|
task,
|
|
state: 'queued',
|
|
meta: createBackgroundTaskMetadata(task, false),
|
|
};
|
|
const shouldShowQueuedToast =
|
|
showQueuedToast && willTaskWaitInQueue(task, current);
|
|
this.store([...current, pending]);
|
|
|
|
try {
|
|
const id = await NativeBackgroundTasks.enqueue(
|
|
task.name,
|
|
JSON.stringify(task),
|
|
pending.meta.name,
|
|
pending.meta.progressText || getString('common.preparing'),
|
|
allowsDuplicateTask(task),
|
|
getBackgroundTaskQueueName(task),
|
|
);
|
|
const latest = this.getSnapshot().filter(item => item.id !== pending.id);
|
|
if (!latest.some(item => item.id === id)) {
|
|
latest.push({ ...pending, id });
|
|
}
|
|
this.store(latest);
|
|
if (shouldShowQueuedToast) {
|
|
showToast(
|
|
getString('notifications.taskQueued', { task: pending.meta.name }),
|
|
);
|
|
}
|
|
} catch (error) {
|
|
this.store(this.getSnapshot().filter(item => item.id !== pending.id));
|
|
showToast(
|
|
`${pending.meta.name}: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
private updateProgress(
|
|
taskId: string,
|
|
transformer: (meta: BackgroundTaskMetadata) => BackgroundTaskMetadata,
|
|
) {
|
|
this.throwIfInterrupted(taskId);
|
|
|
|
const queue = this.getSnapshot();
|
|
const index = queue.findIndex(task => task.id === taskId);
|
|
if (index < 0) return;
|
|
const meta = transformer(queue[index].meta);
|
|
queue[index] = { ...queue[index], meta, state: 'running' };
|
|
this.store(queue);
|
|
NativeBackgroundTasks.updateProgress(
|
|
taskId,
|
|
meta.progress ?? -1,
|
|
meta.progressText ?? '',
|
|
).catch(() => undefined);
|
|
}
|
|
|
|
private throwIfInterrupted(taskId: string) {
|
|
const interruption = this.interruptedTasks.get(taskId);
|
|
if (interruption) throw new Error(`Background task ${interruption}`);
|
|
}
|
|
|
|
private finishLocalExecution(taskId: string) {
|
|
const interruption = this.interruptedTasks.get(taskId);
|
|
if (interruption === 'pause') {
|
|
this.store(
|
|
this.getSnapshot().map(item =>
|
|
item.id === taskId
|
|
? {
|
|
...item,
|
|
state: 'paused',
|
|
meta: { ...item.meta, isRunning: false },
|
|
}
|
|
: item,
|
|
),
|
|
);
|
|
} else {
|
|
this.store(this.getSnapshot().filter(item => item.id !== taskId));
|
|
}
|
|
this.interruptedTasks.delete(taskId);
|
|
}
|
|
|
|
private store(tasks: QueuedBackgroundTask[]) {
|
|
setMMKVObject(BACKGROUND_TASKS_STORE_KEY, tasks);
|
|
}
|
|
}
|