refactor: Migrate Background Tasks to Native Android Scheduling (#1889)

* refactor: Restructure Application Architecture

* feat: Support Batched Downloads And EPUB Imports

* feat: Add Download Checkpoint Recovery
This commit is contained in:
Rajarshee Chatterjee
2026-07-22 09:21:18 +05:30
committed by GitHub
parent 3e1e211098
commit e4cf64a112
67 changed files with 2047 additions and 897 deletions
+13 -18
View File
@@ -9,7 +9,6 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler';
import LottieSplashScreen from 'react-native-lottie-splash-screen';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { Provider as PaperProvider } from 'react-native-paper';
import * as Notifications from 'expo-notifications';
import AppErrorBoundary, {
ErrorFallback,
@@ -18,33 +17,29 @@ import AppErrorBoundary, {
import Main from './src/navigators/Main';
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
import { useInitDatabase } from '@database/db';
import { useInitializeAppServices } from '@hooks/common/useInitializeAppServices';
import { ThemeProvider } from '@hooks/persisted/useTheme';
Notifications.setNotificationHandler({
handleNotification: async () => {
return {
shouldPlaySound: false,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
};
},
});
const App = () => {
const state = useInitDatabase();
const { success: databaseReady, error: databaseError } = useInitDatabase();
const { ready: servicesReady, error: servicesError } =
useInitializeAppServices(Boolean(databaseReady));
useEffect(() => {
if (state.success || state.error) {
if ((databaseReady && servicesReady) || databaseError || servicesError) {
LottieSplashScreen.hide();
}
}, [state.success, state.error]);
}, [databaseReady, databaseError, servicesReady, servicesError]);
if (state.error) {
return <ErrorFallback error={state.error} resetError={() => null} />;
const initializationError = databaseError || servicesError;
if (initializationError) {
return (
<ErrorFallback error={initializationError} resetError={() => null} />
);
}
if (!state.success) {
if (!databaseReady || !servicesReady) {
return null;
}
+5
View File
@@ -1,6 +1,7 @@
apply plugin: "com.android.application"
apply plugin: "com.facebook.react"
apply plugin: 'org.jetbrains.kotlin.android'
apply plugin: 'org.jetbrains.kotlin.kapt'
/**
* This is the configuration block to customize your React Native Android app.
@@ -139,6 +140,10 @@ dependencies {
implementation("com.facebook.react:react-android")
implementation 'androidx.core:core-ktx:1.15.0'
implementation 'androidx.media:media:1.7.0'
implementation 'androidx.work:work-runtime:2.11.2'
implementation 'androidx.room:room-runtime:2.8.4'
implementation 'androidx.room:room-ktx:2.8.4'
kapt 'androidx.room:room-compiler:2.8.4'
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
+6 -4
View File
@@ -1,4 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<!-- Internet -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@@ -10,6 +10,8 @@
<!-- For background jobs -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -20,9 +22,9 @@
</intent>
</queries>
<application android:name=".MainApplication" android:largeHeap="true" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher" android:allowBackup="true" android:theme="@style/AppTheme" android:usesCleartextTraffic="true" android:supportsRtl="true">
<meta-data android:name="expo.modules.notifications.default_notification_color" android:resource="@color/notification_icon_color"/>
<meta-data android:name="expo.modules.notifications.default_notification_icon" android:resource="@drawable/notification_icon"/>
<service android:name="com.asterinet.react.bgactions.RNBackgroundActionsTask" android:foregroundServiceType="shortService"/>
<service android:name="androidx.work.impl.foreground.SystemForegroundService" android:foregroundServiceType="dataSync" tools:node="merge"/>
<service android:name="com.rajarsheechatterjee.background.LNReaderHeadlessTaskService" android:exported="false"/>
<receiver android:name="com.rajarsheechatterjee.background.TaskActionReceiver" android:exported="false"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode|locale|layoutDirection" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:exported="true" android:theme="@style/Theme.App.SplashScreen" android:screenOrientation="unspecified">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
@@ -15,6 +15,7 @@ import com.rajarsheechatterjee.NativeFile.NativePackage
import com.rajarsheechatterjee.NativeVolumeButtonListener.NativeVolumeButtonListenerPackage
import com.rajarsheechatterjee.NativeTTSMediaControl.NativeTTSMediaControlPackage
import com.rajarsheechatterjee.NativeZipArchive.NativeZipArchivePackage
import com.rajarsheechatterjee.background.NativeBackgroundTasksPackage
import expo.modules.ApplicationLifecycleDispatcher
class MainApplication : Application(), ReactApplication {
@@ -27,6 +28,7 @@ class MainApplication : Application(), ReactApplication {
add(NativeTTSMediaControlPackage())
add(NativeVolumeButtonListenerPackage())
add(NativeZipArchivePackage())
add(NativeBackgroundTasksPackage())
},
)
}
@@ -1,7 +1,10 @@
package com.rajarsheechatterjee.NativeFile
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.os.Build
import com.facebook.react.bridge.BaseActivityEventListener
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReadableMap
@@ -13,8 +16,12 @@ import com.facebook.react.modules.network.CookieJarContainer
import com.facebook.react.modules.network.ForwardingCookieHandler
import com.facebook.react.modules.network.OkHttpClientProvider
import com.lnreader.spec.NativeFileSpec
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import okhttp3.Call
import okhttp3.Callback
@@ -31,20 +38,85 @@ import java.io.InputStream
import java.io.OutputStream
import java.io.PushbackInputStream
import java.util.zip.GZIPInputStream
import kotlin.coroutines.coroutineContext
class NativeFile(context: ReactApplicationContext) :
NativeFileSpec(context) {
private val BUFFER_SIZE = 4096
private val okHttpClient = OkHttpClientProvider.createClient()
private val coroutineScope = CoroutineScope(Dispatchers.IO)
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var pendingDocumentPromise: Promise? = null
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
val promise = pendingDocumentPromise ?: return
pendingDocumentPromise = null
val uri = data?.data
if (resultCode != Activity.RESULT_OK || uri == null) {
promise.reject("ECANCELLED", "Document selection was cancelled")
return
}
try {
val flags = data.flags and
(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
reactApplicationContext.contentResolver.takePersistableUriPermission(uri, flags)
} catch (_: SecurityException) {
// Some providers do not support persisted grants.
}
promise.resolve(uri.toString())
}
}
init {
reactApplicationContext.addActivityEventListener(activityEventListener)
val cookieContainer = okHttpClient.cookieJar as CookieJarContainer
val cookieHandler = ForwardingCookieHandler(reactApplicationContext)
cookieContainer.setCookieJar(JavaNetCookieJar(cookieHandler))
}
override fun createDocument(filename: String, mimeType: String, promise: Promise) {
launchDocumentIntent(
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = mimeType
putExtra(Intent.EXTRA_TITLE, filename)
},
CREATE_DOCUMENT_REQUEST,
promise,
)
}
override fun pickDocument(mimeType: String, promise: Promise) {
launchDocumentIntent(
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = mimeType
},
PICK_DOCUMENT_REQUEST,
promise,
)
}
private fun launchDocumentIntent(intent: Intent, requestCode: Int, promise: Promise) {
val activity = currentActivity
if (activity == null) {
promise.reject("ENOACTIVITY", "A visible activity is required to select a document")
return
}
if (pendingDocumentPromise != null) {
promise.reject("EBUSY", "Another document selection is already active")
return
}
pendingDocumentPromise = promise
intent.addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION,
)
activity.startActivityForResult(intent, requestCode)
}
private fun getFileUri(filepath: String): Uri {
var uri = Uri.parse(filepath)
if (uri.scheme == null) {
@@ -73,34 +145,60 @@ class NativeFile(context: ReactApplicationContext) :
?: throw Exception("ENOENT: could not open an output stream for '$filepath'")
}
override fun writeFile(path: String, content: String) {
try {
val fw = FileWriter(path)
fw.write(content)
fw.close()
} catch (e: IOException) {
throw Exception("Failed to write file '$path': ${e.message}")
override fun writeFile(path: String, content: String, promise: Promise) {
coroutineScope.launch {
try {
FileWriter(path).use { it.write(content) }
promise.resolve(null)
} catch (e: Exception) {
rejectFileOperation(promise, "write", path, e)
}
}
}
override fun readFile(path: String): String {
val file = File(path)
if (!file.exists()) {
throw Exception("File not found: '$path'")
override fun readFile(path: String, promise: Promise) {
coroutineScope.launch {
try {
val file = File(path)
if (!file.exists()) {
promise.reject("ENOENT", "File not found: '$path'")
return@launch
}
promise.resolve(file.bufferedReader().use { it.readText() })
} catch (e: Exception) {
rejectFileOperation(promise, "read", path, e)
}
}
return file.bufferedReader().readText()
}
override fun copyFile(filepath: String, destPath: String) {
copyFileContent(filepath, destPath)
override fun copyFile(filepath: String, destPath: String, promise: Promise) {
coroutineScope.launch {
try {
copyFileContent(filepath, destPath)
promise.resolve(null)
} catch (e: Exception) {
rejectFileOperation(promise, "copy", filepath, e)
}
}
}
override fun moveFile(filepath: String, destPath: String) {
val inFile = File(filepath)
copyFileContent(filepath, destPath, { inFile.delete() })
override fun moveFile(filepath: String, destPath: String, promise: Promise) {
coroutineScope.launch {
try {
val inFile = File(filepath)
copyFileContent(filepath, destPath) {
if (!inFile.delete()) {
throw IOException("Failed to delete source file '$filepath'")
}
}
promise.resolve(null)
} catch (e: Exception) {
rejectFileOperation(promise, "move", filepath, e)
}
}
}
private fun copyFileContent(
private suspend fun copyFileContent(
filepath: String,
destPath: String,
onDone: (() -> Unit)? = null,
@@ -113,6 +211,7 @@ class NativeFile(context: ReactApplicationContext) :
val buffer = ByteArray(BUFFER_SIZE)
var length: Int
while (inputStream.read(buffer).also { length = it } > 0) {
coroutineContext.ensureActive()
outputStream.write(buffer, 0, length)
}
} finally {
@@ -129,44 +228,87 @@ class NativeFile(context: ReactApplicationContext) :
}
}
override fun exists(filepath: String) = File(filepath).exists()
override fun mkdir(filepath: String) {
val file = File(filepath)
if (!file.exists()) {
val created = file.mkdirs()
if (!created) throw Exception("Directory could not be created")
override fun exists(filepath: String, promise: Promise) {
coroutineScope.launch {
try {
promise.resolve(File(filepath).exists())
} catch (e: Exception) {
rejectFileOperation(promise, "inspect", filepath, e)
}
}
}
private fun deleteRecursive(fileOrDirectory: File) {
override fun mkdir(filepath: String, promise: Promise) {
coroutineScope.launch {
try {
val file = File(filepath)
if (!file.exists() && !file.mkdirs()) {
throw IOException("Directory could not be created")
}
promise.resolve(null)
} catch (e: Exception) {
rejectFileOperation(promise, "create directory", filepath, e)
}
}
}
private suspend fun deleteRecursive(fileOrDirectory: File) {
coroutineContext.ensureActive()
if (fileOrDirectory.isDirectory) {
for (child in fileOrDirectory.listFiles()!!) {
for (child in fileOrDirectory.listFiles().orEmpty()) {
deleteRecursive(child)
}
}
fileOrDirectory.delete()
}
override fun unlink(filepath: String) {
val file = File(filepath)
if (!file.exists()) return
deleteRecursive(file)
}
override fun readDir(directory: String): WritableArray {
val file = File(directory)
if (!file.exists()) throw Exception("Folder does not exist")
val files = file.listFiles()
val fileMaps: WritableArray = WritableNativeArray()
for (childFile in files!!) {
val fileMap: WritableMap = WritableNativeMap()
fileMap.putString("name", childFile.name)
fileMap.putString("path", childFile.absolutePath)
fileMap.putBoolean("isDirectory", childFile.isDirectory)
fileMaps.pushMap(fileMap)
if (!fileOrDirectory.delete() && fileOrDirectory.exists()) {
throw IOException("Failed to delete '${fileOrDirectory.absolutePath}'")
}
return fileMaps
}
override fun unlink(filepath: String, promise: Promise) {
coroutineScope.launch {
try {
val file = File(filepath)
if (file.exists()) {
deleteRecursive(file)
}
promise.resolve(null)
} catch (e: Exception) {
rejectFileOperation(promise, "delete", filepath, e)
}
}
}
override fun readDir(directory: String, promise: Promise) {
coroutineScope.launch {
try {
val file = File(directory)
if (!file.exists()) {
promise.reject("ENOENT", "Folder does not exist: '$directory'")
return@launch
}
val fileMaps: WritableArray = WritableNativeArray()
for (childFile in file.listFiles().orEmpty()) {
coroutineContext.ensureActive()
val fileMap: WritableMap = WritableNativeMap()
fileMap.putString("name", childFile.name)
fileMap.putString("path", childFile.absolutePath)
fileMap.putBoolean("isDirectory", childFile.isDirectory)
fileMaps.pushMap(fileMap)
}
promise.resolve(fileMaps)
} catch (e: Exception) {
rejectFileOperation(promise, "list", directory, e)
}
}
}
private fun rejectFileOperation(promise: Promise, operation: String, path: String, error: Exception) {
if (error is CancellationException) {
promise.reject("ECANCELLED", "File operation was cancelled", error)
return
}
val code = if (error is SecurityException) "EACCES" else "EIO"
promise.reject(code, "Failed to $operation '$path': ${error.message}", error)
}
private fun decompressStream(input: InputStream?): InputStream {
@@ -245,4 +387,17 @@ class NativeFile(context: ReactApplicationContext) :
}
return constants
}
override fun invalidate() {
reactApplicationContext.removeActivityEventListener(activityEventListener)
pendingDocumentPromise?.reject("ECANCELLED", "Native file module invalidated")
pendingDocumentPromise = null
coroutineScope.cancel()
super.invalidate()
}
companion object {
private const val CREATE_DOCUMENT_REQUEST = 48120
private const val PICK_DOCUMENT_REQUEST = 48121
}
}
@@ -0,0 +1,39 @@
package com.rajarsheechatterjee.background
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
@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 * FROM background_tasks WHERE id = :id LIMIT 1")
suspend fun get(id: String): BackgroundTaskEntity?
@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("UPDATE background_tasks SET state = :state, updatedAt = :updatedAt WHERE id = :id AND state = 'running'")
suspend fun finishRunning(id: String, state: String, updatedAt: Long)
@Query("UPDATE background_tasks SET state = :state, attempt = attempt + 1, updatedAt = :updatedAt WHERE id = :id")
suspend fun markRunning(id: String, state: String, updatedAt: Long)
@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 checkpoint = :checkpoint, updatedAt = :updatedAt WHERE id = :id")
suspend fun updateCheckpoint(id: String, checkpoint: String?, updatedAt: Long)
@Query("UPDATE background_tasks SET workId = :workId, updatedAt = :updatedAt WHERE id = :id")
suspend fun assignWork(id: String, workId: String, updatedAt: Long)
}
@@ -0,0 +1,24 @@
package com.rajarsheechatterjee.background
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
@Database(entities = [BackgroundTaskEntity::class], version = 1, exportSchema = false)
abstract class BackgroundTaskDatabase : RoomDatabase() {
abstract fun tasks(): BackgroundTaskDao
companion object {
@Volatile private var instance: BackgroundTaskDatabase? = null
fun get(context: Context): BackgroundTaskDatabase =
instance ?: synchronized(this) {
instance ?: Room.databaseBuilder(
context.applicationContext,
BackgroundTaskDatabase::class.java,
"lnreader-background-tasks.db",
).build().also { instance = it }
}
}
}
@@ -0,0 +1,32 @@
package com.rajarsheechatterjee.background
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "background_tasks")
data class BackgroundTaskEntity(
@PrimaryKey val id: String,
val type: String,
val payload: String,
val title: String,
val description: String,
val state: String,
val progress: Double?,
val progressText: String?,
val checkpoint: String?,
val attempt: Int,
val workId: String?,
val createdAt: Long,
val updatedAt: Long,
)
object BackgroundTaskState {
const val QUEUED = "queued"
const val RUNNING = "running"
const val PAUSED = "paused"
const val SUCCEEDED = "succeeded"
const val FAILED = "failed"
const val CANCELLED = "cancelled"
val active = listOf(QUEUED, RUNNING, PAUSED)
}
@@ -0,0 +1,47 @@
package com.rajarsheechatterjee.background
import android.content.Context
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 java.util.UUID
object BackgroundTaskScheduler {
private const val QUEUE_NAME = "lnreader-background-task-queue"
const val TASK_ID = "taskId"
suspend fun enqueue(context: Context, taskId: String): UUID {
val task = BackgroundTaskDatabase.get(context).tasks().get(taskId)
?: throw IllegalArgumentException("Unknown background task: $taskId")
val requestBuilder = OneTimeWorkRequestBuilder<LNReaderTaskWorker>()
.setInputData(Data.Builder().putString(TASK_ID, taskId).build())
.addTag(taskId)
if (task.type in NETWORK_TASKS) {
requestBuilder.setConstraints(
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build(),
)
}
val request = requestBuilder.build()
BackgroundTaskDatabase.get(context).tasks()
.assignWork(taskId, request.id.toString(), System.currentTimeMillis())
WorkManager.getInstance(context).enqueueUniqueWork(
QUEUE_NAME,
ExistingWorkPolicy.APPEND_OR_REPLACE,
request,
)
return request.id
}
private val NETWORK_TASKS = setOf(
"UPDATE_LIBRARY",
"DRIVE_BACKUP",
"DRIVE_RESTORE",
"SELF_HOST_BACKUP",
"SELF_HOST_RESTORE",
"MIGRATE_NOVEL",
"DOWNLOAD_CHAPTER",
)
}
@@ -0,0 +1,18 @@
package com.rajarsheechatterjee.background
import android.content.Intent
import com.facebook.react.HeadlessJsTaskService
import com.facebook.react.bridge.Arguments
import com.facebook.react.jstasks.HeadlessJsTaskConfig
class LNReaderHeadlessTaskService : HeadlessJsTaskService() {
override fun getTaskConfig(intent: Intent?): HeadlessJsTaskConfig? {
val extras = intent?.extras ?: return null
return HeadlessJsTaskConfig(
"LNReaderBackgroundTask",
Arguments.fromBundle(extras),
0,
true,
)
}
}
@@ -0,0 +1,90 @@
package com.rajarsheechatterjee.background
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import kotlinx.coroutines.CancellationException
class LNReaderTaskWorker(
appContext: Context,
workerParams: WorkerParameters,
) : CoroutineWorker(appContext, workerParams) {
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) {
return Result.success()
}
dao.markRunning(taskId, BackgroundTaskState.RUNNING, System.currentTimeMillis())
val runningTask = dao.get(taskId) ?: return Result.failure()
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 {
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.update(applicationContext, it) }
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.update(applicationContext, it) }
// A logical task failure must not cancel the rest of WorkManager's chain.
Result.success()
}
}
}
} catch (error: CancellationException) {
val latestState = dao.get(taskId)?.state
if (latestState !in listOf(BackgroundTaskState.PAUSED, BackgroundTaskState.CANCELLED)) {
dao.updateState(taskId, BackgroundTaskState.QUEUED, System.currentTimeMillis())
}
throw error
} finally {
TaskExecutionRegistry.cancel(taskId)
}
}
private fun createForegroundInfo(task: BackgroundTaskEntity): ForegroundInfo {
val id = TaskNotificationFactory.notificationId(task.id)
val notification = TaskNotificationFactory.build(applicationContext, task)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ForegroundInfo(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
ForegroundInfo(id, notification)
}
}
}
@@ -0,0 +1,188 @@
package com.rajarsheechatterjee.background
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.lnreader.spec.NativeBackgroundTasksSpec
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import java.util.UUID
class NativeBackgroundTasks(
private val context: ReactApplicationContext,
) : NativeBackgroundTasksSpec(context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val dao by lazy { BackgroundTaskDatabase.get(context).tasks() }
init {
reactContext = context
}
override fun enqueue(
type: String,
payload: String,
title: String,
description: String,
allowsDuplicates: Boolean,
promise: Promise,
) = runPromise(promise) {
if (!allowsDuplicates) {
dao.getActiveByType(type)?.let {
promise.resolve(it.id)
return@runPromise
}
}
val now = System.currentTimeMillis()
val task = BackgroundTaskEntity(
id = UUID.randomUUID().toString(),
type = type,
payload = payload,
title = title,
description = description,
state = BackgroundTaskState.QUEUED,
progress = null,
progressText = null,
checkpoint = null,
attempt = 0,
workId = null,
createdAt = now,
updatedAt = now,
)
dao.insert(task)
BackgroundTaskScheduler.enqueue(context, task.id)
promise.resolve(task.id)
}
override fun getTasks(promise: Promise) = runPromise(promise) {
val result = Arguments.createArray()
dao.getAll().forEach { task ->
result.pushMap(Arguments.createMap().apply {
putString("id", task.id)
putString("type", task.type)
putString("payload", task.payload)
putString("title", task.title)
putString("description", task.description)
putString("state", task.state)
task.progress?.let { putDouble("progress", it) }
task.progressText?.let { putString("progressText", it) }
task.checkpoint?.let { putString("checkpoint", it) }
putInt("attempt", task.attempt)
putDouble("createdAt", task.createdAt.toDouble())
putDouble("updatedAt", task.updatedAt.toDouble())
})
}
promise.resolve(result)
}
override fun pause(taskId: String, promise: Promise) = runPromise(promise) {
requireTask(taskId)
dao.updateState(taskId, BackgroundTaskState.PAUSED, System.currentTimeMillis())
if (TaskExecutionRegistry.isActive(taskId)) {
emitInterruption(taskId, "pause")
}
dao.get(taskId)?.let { TaskNotificationFactory.update(context, it) }
promise.resolve(null)
}
override fun resume(taskId: String, promise: Promise) = runPromise(promise) {
requireTask(taskId)
if (TaskExecutionRegistry.isActive(taskId)) {
throw IllegalStateException("Task is still pausing; try resuming again shortly")
}
dao.updateState(taskId, BackgroundTaskState.QUEUED, System.currentTimeMillis())
BackgroundTaskScheduler.enqueue(context, taskId)
promise.resolve(null)
}
override fun cancel(taskId: String, promise: Promise) = runPromise(promise) {
requireTask(taskId)
dao.updateState(taskId, BackgroundTaskState.CANCELLED, System.currentTimeMillis())
if (TaskExecutionRegistry.isActive(taskId)) {
emitInterruption(taskId, "cancel")
}
dao.updateCheckpoint(taskId, null, System.currentTimeMillis())
TaskNotificationFactory.dismiss(context, taskId)
promise.resolve(null)
}
override fun updateProgress(
taskId: String,
progress: Double,
progressText: String,
promise: Promise,
) = runPromise(promise) {
dao.updateProgress(
taskId,
progress.takeUnless { it < 0 },
progressText.ifEmpty { null },
System.currentTimeMillis(),
)
dao.get(taskId)?.let { TaskNotificationFactory.update(context, it) }
promise.resolve(null)
}
override fun updateCheckpoint(taskId: String, checkpoint: String, promise: Promise) =
runPromise(promise) {
requireTask(taskId)
dao.updateCheckpoint(taskId, checkpoint, System.currentTimeMillis())
promise.resolve(null)
}
override fun complete(taskId: String, promise: Promise) = runPromise(promise) {
dao.updateCheckpoint(taskId, null, System.currentTimeMillis())
dao.finishRunning(taskId, BackgroundTaskState.SUCCEEDED, System.currentTimeMillis())
TaskExecutionRegistry.complete(taskId, TaskExecutionResult.Success)
promise.resolve(null)
}
override fun fail(taskId: String, error: String, shouldRetry: Boolean, promise: Promise) =
runPromise(promise) {
dao.updateProgress(taskId, null, error, System.currentTimeMillis())
dao.finishRunning(
taskId,
if (shouldRetry) BackgroundTaskState.QUEUED else BackgroundTaskState.FAILED,
System.currentTimeMillis(),
)
TaskExecutionRegistry.complete(taskId, TaskExecutionResult.Failure(error, shouldRetry))
promise.resolve(null)
}
private suspend fun requireTask(taskId: String): BackgroundTaskEntity =
dao.get(taskId) ?: throw IllegalArgumentException("Unknown background task: $taskId")
private fun runPromise(promise: Promise, block: suspend () -> Unit) {
scope.launch {
try {
block()
} catch (error: CancellationException) {
promise.reject("ECANCELLED", "Background task operation cancelled", error)
} catch (error: Exception) {
promise.reject("EBACKGROUNDTASK", error.message, error)
}
}
}
override fun invalidate() {
if (reactContext === context) reactContext = null
scope.cancel()
super.invalidate()
}
companion object {
@Volatile
private var reactContext: ReactApplicationContext? = null
fun emitInterruption(taskId: String, action: String) {
reactContext?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
?.emit("LNReaderTaskInterrupted", Arguments.createMap().apply {
putString("taskId", taskId)
putString("action", action)
})
}
}
}
@@ -0,0 +1,26 @@
package com.rajarsheechatterjee.background
import com.facebook.react.BaseReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.model.ReactModuleInfo
import com.facebook.react.module.model.ReactModuleInfoProvider
import com.lnreader.spec.NativeBackgroundTasksSpec
class NativeBackgroundTasksPackage : BaseReactPackage() {
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
if (name == NativeBackgroundTasksSpec.NAME) NativeBackgroundTasks(reactContext) else null
override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
mapOf(
NativeBackgroundTasksSpec.NAME to ReactModuleInfo(
NativeBackgroundTasksSpec.NAME,
NativeBackgroundTasksSpec.NAME,
canOverrideExistingModule = false,
needsEagerInit = false,
isCxxModule = false,
isTurboModule = true,
),
)
}
}
@@ -0,0 +1,44 @@
package com.rajarsheechatterjee.background
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class TaskActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val taskId = intent.getStringExtra(TaskNotificationFactory.EXTRA_TASK_ID) ?: return
val pendingResult = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
val dao = BackgroundTaskDatabase.get(context).tasks()
dao.get(taskId) ?: return@launch
when (intent.action) {
TaskNotificationFactory.ACTION_PAUSE -> {
dao.updateState(taskId, BackgroundTaskState.PAUSED, System.currentTimeMillis())
if (TaskExecutionRegistry.isActive(taskId)) {
NativeBackgroundTasks.emitInterruption(taskId, "pause")
}
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)
}
TaskNotificationFactory.ACTION_CANCEL -> {
dao.updateState(taskId, BackgroundTaskState.CANCELLED, System.currentTimeMillis())
if (TaskExecutionRegistry.isActive(taskId)) {
NativeBackgroundTasks.emitInterruption(taskId, "cancel")
}
TaskNotificationFactory.dismiss(context, taskId)
}
}
} finally {
pendingResult.finish()
}
}
}
}
@@ -0,0 +1,29 @@
package com.rajarsheechatterjee.background
import kotlinx.coroutines.CompletableDeferred
import java.util.concurrent.ConcurrentHashMap
sealed interface TaskExecutionResult {
data object Success : TaskExecutionResult
data class Failure(val error: String, val shouldRetry: Boolean) : TaskExecutionResult
}
object TaskExecutionRegistry {
private val executions = ConcurrentHashMap<String, CompletableDeferred<TaskExecutionResult>>()
fun register(taskId: String): CompletableDeferred<TaskExecutionResult> {
val execution = CompletableDeferred<TaskExecutionResult>()
executions.put(taskId, execution)?.cancel()
return execution
}
fun complete(taskId: String, result: TaskExecutionResult) {
executions.remove(taskId)?.complete(result)
}
fun cancel(taskId: String) {
executions.remove(taskId)?.cancel()
}
fun isActive(taskId: String): Boolean = executions.containsKey(taskId)
}
@@ -0,0 +1,103 @@
package com.rajarsheechatterjee.background
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import com.rajarsheechatterjee.LNReader.R
object TaskNotificationFactory {
const val CHANNEL_ID = "background_tasks_v2"
const val ACTION_PAUSE = "com.rajarsheechatterjee.LNReader.background.PAUSE"
const val ACTION_RESUME = "com.rajarsheechatterjee.LNReader.background.RESUME"
const val ACTION_CANCEL = "com.rajarsheechatterjee.LNReader.background.CANCEL"
const val EXTRA_TASK_ID = "taskId"
fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
"Background tasks",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Downloads, imports, backups, and library updates"
setSound(null, null)
},
)
}
fun notificationId(taskId: String): Int = taskId.hashCode().and(Int.MAX_VALUE).coerceAtLeast(1)
fun build(context: Context, task: BackgroundTaskEntity): Notification {
ensureChannel(context)
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
val contentIntent = launchIntent?.let {
PendingIntent.getActivity(
context,
notificationId(task.id),
it,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
val toggleAction = if (task.state == BackgroundTaskState.PAUSED) ACTION_RESUME else ACTION_PAUSE
val toggleLabel = if (task.state == BackgroundTaskState.PAUSED) "Resume" else "Pause"
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.notification_icon)
.setContentTitle(task.title)
.setContentText(task.progressText ?: task.description)
.setContentIntent(contentIntent)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setOngoing(task.state == BackgroundTaskState.RUNNING || task.state == BackgroundTaskState.QUEUED)
.setAutoCancel(task.state == BackgroundTaskState.SUCCEEDED || task.state == BackgroundTaskState.FAILED)
.setOnlyAlertOnce(true)
if (task.state in listOf(BackgroundTaskState.QUEUED, BackgroundTaskState.RUNNING, BackgroundTaskState.PAUSED)) {
builder.addAction(0, toggleLabel, actionIntent(context, task.id, toggleAction, 1))
.addAction(0, "Cancel", actionIntent(context, task.id, ACTION_CANCEL, 2))
}
val progress = task.progress
if (task.state == BackgroundTaskState.SUCCEEDED) {
builder.setContentText("Completed").setProgress(0, 0, false)
} else if (progress == null) {
builder.setProgress(100, 0, true)
} else {
builder.setProgress(100, (progress.coerceIn(0.0, 1.0) * 100).toInt(), false)
}
return builder.build()
}
fun update(context: Context, task: BackgroundTaskEntity) {
context.getSystemService(NotificationManager::class.java)
.notify(notificationId(task.id), build(context, task))
}
fun dismiss(context: Context, taskId: String) {
context.getSystemService(NotificationManager::class.java)
.cancel(notificationId(taskId))
}
private fun actionIntent(
context: Context,
taskId: String,
action: String,
requestOffset: Int,
): PendingIntent = PendingIntent.getBroadcast(
context,
notificationId(taskId) + requestOffset,
Intent(context, TaskActionReceiver::class.java).apply {
this.action = action
putExtra(EXTRA_TASK_ID, taskId)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
+7 -1
View File
@@ -1,7 +1,13 @@
import 'react-native-gesture-handler';
import { registerRootComponent } from 'expo';
import { I18nManager } from 'react-native';
import { AppRegistry, I18nManager } from 'react-native';
import { i18n } from './strings/translations';
import { runHeadlessBackgroundTask } from './src/services/backgroundTasks';
AppRegistry.registerHeadlessTask(
'LNReaderBackgroundTask',
() => runHeadlessBackgroundTask,
);
const isRTL = i18n.locale.startsWith('ar') || i18n.locale.startsWith('he');
I18nManager.allowRTL(isRTL);
-2
View File
@@ -83,7 +83,6 @@
"expo-linking": "~55.0.9",
"expo-localization": "~55.0.9",
"expo-navigation-bar": "~55.0.9",
"expo-notifications": "~55.0.14",
"expo-speech": "~55.0.9",
"expo-web-browser": "~55.0.10",
"htmlparser2": "^12.0.0",
@@ -94,7 +93,6 @@
"protobufjs": "^8.0.0",
"react": "^19.2.4",
"react-native": "^0.83.4",
"react-native-background-actions": "^4.0.1",
"react-native-device-info": "^15.0.2",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-drawer-layout": "^4.2.2",
-56
View File
@@ -115,9 +115,6 @@ importers:
expo-navigation-bar:
specifier: ~55.0.9
version: 55.0.9(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)
expo-notifications:
specifier: ~55.0.14
version: 55.0.14(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
expo-speech:
specifier: ~55.0.9
version: 55.0.9(expo@55.0.9)
@@ -148,9 +145,6 @@ importers:
react-native:
specifier: ^0.83.4
version: 0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4)
react-native-background-actions:
specifier: ^4.0.1
version: 4.0.1(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))
react-native-device-info:
specifier: ^15.0.2
version: 15.0.2(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))
@@ -2400,9 +2394,6 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0
badgin@1.2.3:
resolution: {integrity: sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -3325,9 +3316,6 @@ packages:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
@@ -3348,11 +3336,6 @@ packages:
resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
expo-application@55.0.10:
resolution: {integrity: sha512-5ccf+S6hsQz+doi907TOJxKzV5AKgAgw004z4FoDWSoGhfab0LUPg6uyvOspuU4cbNvqw8EAy08hZbVO8nKc9Q==}
peerDependencies:
expo: '*'
expo-asset@55.0.10:
resolution: {integrity: sha512-wxjNBKIaDyachq7oJgVlWVFzZ6SnNpJFJhkkcymXoTPt5O3XmDM+a6fT91xQQawCXTyZuCc1sNxKMetEofeYkg==}
peerDependencies:
@@ -3438,13 +3421,6 @@ packages:
react: '*'
react-native: '*'
expo-notifications@55.0.14:
resolution: {integrity: sha512-fwWTd0OK82Yj2MLJJK0cIgaRtAu8OUcjGuucdtsp/dOsErqBsGQQWpotoEXoRPrDrCL4sHwSvg9QzGdeouJ/jQ==}
peerDependencies:
expo: '*'
react: '*'
react-native: '*'
expo-server@55.0.6:
resolution: {integrity: sha512-xI72FTm469FfuuBL2R5aNtthgH+GR7ygOpsx/KcPS0K8AZaZd7VjtEExbzn9/qyyYkWW3T+3dAmCDKOMX8gdmQ==}
engines: {node: '>=20.16.0'}
@@ -5223,11 +5199,6 @@ packages:
react-is@19.2.4:
resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==}
react-native-background-actions@4.0.1:
resolution: {integrity: sha512-LADhnb4ag1oH5Lotq0j8K9e2cFmrafFyg2PCME88VkTjqDUgNcJonkNdMCTHN0N3fh+hwAA7nDR4Cxkj9Q8eCw==}
peerDependencies:
react-native: '>=0.47.0'
react-native-device-info@15.0.2:
resolution: {integrity: sha512-dd71eXG2l3Cwp66IvKNadMTB8fhU3PEjyVddI97sYan+D4bgIAUmgGDhbSOFvHcGavksb2U17kiQYaDiK2WK2g==}
peerDependencies:
@@ -9213,8 +9184,6 @@ snapshots:
babel-plugin-jest-hoist: 29.6.3
babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0)
badgin@1.2.3: {}
balanced-match@1.0.2: {}
balanced-match@4.0.4: {}
@@ -10165,8 +10134,6 @@ snapshots:
event-target-shim@5.0.1: {}
eventemitter3@4.0.7: {}
events@3.3.0: {}
execa@5.1.1:
@@ -10193,10 +10160,6 @@ snapshots:
jest-message-util: 29.7.0
jest-util: 29.7.0
expo-application@55.0.10(expo@55.0.9):
dependencies:
expo: 55.0.9(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-native-webview@13.16.1(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
expo-asset@55.0.10(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
dependencies:
'@expo/image-utils': 0.8.12
@@ -10298,20 +10261,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
expo-notifications@55.0.14(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
dependencies:
'@expo/image-utils': 0.8.12
abort-controller: 3.0.0
badgin: 1.2.3
expo: 55.0.9(@babel/core@7.29.0)(@expo/dom-webview@55.0.3)(react-native-webview@13.16.1(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
expo-application: 55.0.10(expo@55.0.9)
expo-constants: 55.0.9(expo@55.0.9)(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4))(typescript@5.9.3)
react: 19.2.4
react-native: 0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4)
transitivePeerDependencies:
- supports-color
- typescript
expo-server@55.0.6: {}
expo-speech@55.0.9(expo@55.0.9):
@@ -12599,11 +12548,6 @@ snapshots:
react-is@19.2.4: {}
react-native-background-actions@4.0.1(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4)):
dependencies:
eventemitter3: 4.0.7
react-native: 0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4)
react-native-device-info@15.0.2(react-native@0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4)):
dependencies:
react-native: 0.83.4(@babel/core@7.29.0)(@react-native-community/cli@20.1.3(typescript@5.9.3))(@react-native/metro-config@0.83.4(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.4)
+40
View File
@@ -0,0 +1,40 @@
import { TurboModule, TurboModuleRegistry } from 'react-native';
export type NativeBackgroundTaskRecord = {
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 interface Spec extends TurboModule {
enqueue(
type: string,
payload: string,
title: string,
description: string,
allowsDuplicates: boolean,
): Promise<string>;
getTasks(): Promise<NativeBackgroundTaskRecord[]>;
pause(taskId: string): Promise<void>;
resume(taskId: string): Promise<void>;
cancel(taskId: string): Promise<void>;
updateProgress(
taskId: string,
progress: number,
progressText: string,
): Promise<void>;
updateCheckpoint(taskId: string, checkpoint: string): Promise<void>;
complete(taskId: string): Promise<void>;
fail(taskId: string, error: string, shouldRetry: boolean): Promise<void>;
}
export default TurboModuleRegistry.getEnforcing<Spec>('NativeBackgroundTasks');
+10 -8
View File
@@ -7,20 +7,22 @@ interface ReadDirResult {
}
export interface Spec extends TurboModule {
writeFile: (path: string, content: string) => void;
readFile: (path: string) => string;
copyFile: (sourcePath: string, destPath: string) => void;
moveFile: (sourcePath: string, destPath: string) => void;
exists: (filePath: string) => boolean;
writeFile: (path: string, content: string) => Promise<void>;
readFile: (path: string) => Promise<string>;
copyFile: (sourcePath: string, destPath: string) => Promise<void>;
moveFile: (sourcePath: string, destPath: string) => Promise<void>;
exists: (filePath: string) => Promise<boolean>;
/**
* @description create parents, and do nothing if exists;
*/
mkdir: (filePath: string) => void;
mkdir: (filePath: string) => Promise<void>;
/**
* @description remove recursively
*/
unlink: (filePath: string) => void;
readDir: (dirPath: string) => ReadDirResult[];
unlink: (filePath: string) => Promise<void>;
readDir: (dirPath: string) => Promise<ReadDirResult[]>;
createDocument: (filename: string, mimeType: string) => Promise<string>;
pickDocument: (mimeType: string) => Promise<string>;
downloadFile: (
url: string,
destPath: string,
+14 -6
View File
@@ -175,6 +175,19 @@ export const runDatabaseBootstrap = (executor: SqlExecutor) => {
populateDatabase(executor);
};
let initialization: Promise<void> | undefined;
export const initializeDatabase = () => {
if (!initialization) {
setPragmas(_db);
repairMigrationHistory(_db);
initialization = migrate(drizzleDb, getPendingMigrations(_db)).then(() => {
runDatabaseBootstrap(_db);
});
}
return initialization;
};
type InitDbState = {
success?: boolean;
error?: Error;
@@ -217,13 +230,8 @@ export const useInitDatabase = () => {
const [state, dispatch] = useReducer(fetchReducer, initialState);
useEffect(() => {
dispatch({ type: 'migrating' });
setPragmas(_db);
repairMigrationHistory(_db);
migrate(drizzleDb, getPendingMigrations(_db))
initializeDatabase()
.then(() => {
runDatabaseBootstrap(_db);
dispatch({
type: 'migrated',
payload: true,
+7 -5
View File
@@ -175,14 +175,14 @@ export const markAllChaptersUnread = async (novelId: number): Promise<void> => {
});
};
const deleteDownloadedFiles = (
const deleteDownloadedFiles = async (
pluginId: string,
novelId: number,
chapterId: number,
) => {
try {
const chapterFolder = `${NOVEL_STORAGE}/${pluginId}/${novelId}/${chapterId}`;
NativeFile.unlink(chapterFolder);
await NativeFile.unlink(chapterFolder);
} catch {
throw new Error(getString('novelScreen.deleteChapterError'));
}
@@ -194,7 +194,7 @@ export const deleteChapter = async (
novelId: number,
chapterId: number,
): Promise<void> => {
deleteDownloadedFiles(pluginId, novelId, chapterId);
await deleteDownloadedFiles(pluginId, novelId, chapterId);
await dbManager.write(async tx => {
await tx
.update(chapterSchema)
@@ -214,8 +214,10 @@ export const deleteChapters = async (
}
const chapterIds = chapters.map(chapter => chapter.id);
chapters.forEach(chapter =>
deleteDownloadedFiles(pluginId, novelId, chapter.id),
await Promise.all(
chapters.map(chapter =>
deleteDownloadedFiles(pluginId, novelId, chapter.id),
),
);
await dbManager.write(async tx => {
+4 -4
View File
@@ -53,7 +53,7 @@ export const insertNovelAndChapters = async (
if (novelId) {
if (sourceNovel.cover) {
const novelDir = NOVEL_STORAGE + '/' + pluginId + '/' + novelId;
NativeFile.mkdir(novelDir);
await NativeFile.mkdir(novelDir);
const novelCoverPath = novelDir + '/cover.png';
const novelCoverUri = 'file://' + novelCoverPath;
@@ -327,10 +327,10 @@ export const pickCustomNovelCover = async (novel: NovelInfo) => {
if (image.assets && image.assets[0]) {
const novelDir = NOVEL_STORAGE + '/' + novel.pluginId + '/' + novel.id;
let novelCoverUri = 'file://' + novelDir + '/cover.png';
if (!NativeFile.exists(novelDir)) {
NativeFile.mkdir(novelDir);
if (!(await NativeFile.exists(novelDir))) {
await NativeFile.mkdir(novelDir);
}
NativeFile.copyFile(image.assets[0].uri, novelCoverUri);
await NativeFile.copyFile(image.assets[0].uri, novelCoverUri);
novelCoverUri += '?' + Date.now();
await dbManager.write(async tx => {
await tx
@@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import { initializeInstalledPlugins } from '@plugins/pluginManager';
import { backgroundTasks } from '@services/backgroundTasks';
type AppServicesState = {
ready: boolean;
error?: Error;
};
let initializationPromise: Promise<void> | undefined;
const initializeAppServices = (): Promise<void> => {
if (!initializationPromise) {
initializationPromise = initializeInstalledPlugins()
.then(async () => {
await backgroundTasks.refresh();
})
.catch(error => {
initializationPromise = undefined;
throw error;
});
}
return initializationPromise;
};
export const useInitializeAppServices = (
databaseReady: boolean,
): AppServicesState => {
const [state, setState] = useState<AppServicesState>({ ready: false });
useEffect(() => {
if (!databaseReady) return;
let isActive = true;
initializeAppServices()
.then(() => {
if (isActive) setState({ ready: true });
})
.catch(error => {
if (!isActive) return;
setState({
ready: false,
error: error instanceof Error ? error : new Error(String(error)),
});
});
return () => {
isActive = false;
};
}, [databaseReady]);
return state;
};
+1
View File
@@ -4,6 +4,7 @@ export { default as useBoolean } from './common/useBoolean';
export { usePreviousRouteName } from './common/usePreviousRouteName';
export { useBackHandler } from './common/useBackHandler';
export { useDeviceOrientation } from './common/useDeviceOrientation';
export { useInitializeAppServices } from './common/useInitializeAppServices';
// hook types
export type { UseBooleanReturnType } from './common/useBoolean';
+25 -19
View File
@@ -1,9 +1,11 @@
import { ChapterInfo, NovelInfo } from '@database/types';
import ServiceManager, {
import {
BACKGROUND_TASKS_STORE_KEY,
BackgroundTaskMetadata,
backgroundTasks,
DownloadChapterTask,
QueuedBackgroundTask,
} from '@services/ServiceManager';
} from '@services/backgroundTasks';
import { useMemo } from 'react';
import { useMMKVObject } from 'react-native-mmkv';
@@ -12,7 +14,7 @@ export const CHAPTER_DOWNLOADING = 'CHAPTER_DOWNLOADING';
export default function useDownload() {
const [queue] = useMMKVObject<QueuedBackgroundTask[]>(
ServiceManager.manager.STORE_KEY,
BACKGROUND_TASKS_STORE_KEY,
);
const downloadQueue = useMemo(
@@ -21,36 +23,39 @@ export default function useDownload() {
) as { task: DownloadChapterTask; meta: BackgroundTaskMetadata }[];
const downloadingChapterIds = useMemo(
() => new Set(downloadQueue.map(c => c.task.data.chapterId)),
() =>
new Set(
downloadQueue.flatMap(item =>
item.task.data.chapters.map(chapter => chapter.chapterId),
),
),
[downloadQueue],
);
const downloadChapter = (novel: NovelInfo, chapter: ChapterInfo) =>
ServiceManager.manager.addTask({
backgroundTasks.enqueue({
name: 'DOWNLOAD_CHAPTER',
data: {
chapterId: chapter.id,
novelName: novel.name,
chapterName: chapter.name,
chapters: [{ chapterId: chapter.id, chapterName: chapter.name }],
},
});
const downloadChapters = (novel: NovelInfo, chapters: ChapterInfo[]) =>
ServiceManager.manager.addTask(
chapters.map(chapter => ({
name: 'DOWNLOAD_CHAPTER',
data: {
backgroundTasks.enqueue({
name: 'DOWNLOAD_CHAPTER',
data: {
novelName: novel.name,
chapters: chapters.map(chapter => ({
chapterId: chapter.id,
novelName: novel.name,
chapterName: chapter.name,
},
})),
);
const resumeDownload = () => ServiceManager.manager.resume();
})),
},
});
const resumeDownload = () => backgroundTasks.resumeAll();
const pauseDownload = () => ServiceManager.manager.pause();
const pauseDownload = () => backgroundTasks.pauseAll();
const cancelDownload = () =>
ServiceManager.manager.removeTasksByName('DOWNLOAD_CHAPTER');
const cancelDownload = () => backgroundTasks.cancelByType('DOWNLOAD_CHAPTER');
return {
downloadQueue,
@@ -60,5 +65,6 @@ export default function useDownload() {
downloadChapters,
pauseDownload,
cancelDownload,
enqueueTasks: backgroundTasks.enqueue,
};
}
+18 -15
View File
@@ -1,16 +1,20 @@
import { useLibraryContext } from '@components/Context/LibraryContext';
import ServiceManager, { BackgroundTask } from '@services/ServiceManager';
import {
BACKGROUND_TASKS_STORE_KEY,
backgroundTasks,
QueuedBackgroundTask,
} from '@services/backgroundTasks';
import { DocumentPickerResult } from 'expo-document-picker';
import { useCallback, useEffect, useMemo } from 'react';
import { useMMKVObject } from 'react-native-mmkv';
export default function useImport() {
const { refetchLibrary } = useLibraryContext();
const [queue] = useMMKVObject<BackgroundTask[]>(
ServiceManager.manager.STORE_KEY,
const [queue] = useMMKVObject<QueuedBackgroundTask[]>(
BACKGROUND_TASKS_STORE_KEY,
);
const importQueue = useMemo(
() => queue?.filter(t => t.name === 'IMPORT_EPUB') || [],
() => queue?.filter(t => t.task.name === 'IMPORT_EPUB') || [],
[queue],
);
@@ -20,22 +24,21 @@ export default function useImport() {
const importNovel = useCallback((pickedNovel: DocumentPickerResult) => {
if (pickedNovel.canceled) return;
ServiceManager.manager.addTask(
pickedNovel.assets.map(asset => ({
name: 'IMPORT_EPUB',
data: {
backgroundTasks.enqueue({
name: 'IMPORT_EPUB',
data: {
files: pickedNovel.assets.map(asset => ({
filename: asset.name,
uri: asset.uri,
},
})),
);
})),
},
});
}, []);
const resumeImport = () => ServiceManager.manager.resume();
const resumeImport = () => backgroundTasks.resumeAll();
const pauseImport = () => ServiceManager.manager.pause();
const pauseImport = () => backgroundTasks.pauseAll();
const cancelImport = () =>
ServiceManager.manager.removeTasksByName('IMPORT_EPUB');
const cancelImport = () => backgroundTasks.cancelByType('IMPORT_EPUB');
return {
importQueue,
+2 -2
View File
@@ -52,8 +52,8 @@ export const deleteCachedNovels = async () => {
});
const novelDir = `${NOVEL_STORAGE}/${novel.pluginId}/${novel.id}`;
if (NativeFile.exists(novelDir)) {
NativeFile.unlink(novelDir);
if (await NativeFile.exists(novelDir)) {
await NativeFile.unlink(novelDir);
}
}
@@ -11,12 +11,9 @@ import {
novelPersistence,
} from '../store-helper/contracts';
jest.mock('@services/ServiceManager', () => ({
__esModule: true,
default: {
manager: {
addTask: jest.fn(),
},
jest.mock('@services/backgroundTasks', () => ({
backgroundTasks: {
enqueue: jest.fn(),
},
}));
+2 -1
View File
@@ -8,6 +8,7 @@ import {
installPlugin as _install,
uninstallPlugin as _uninstall,
updatePlugin as _update,
INSTALLED_PLUGINS_KEY,
} from '@plugins/pluginManager';
import { newer } from '@utils/compareVersion';
import { MMKVStorage, getMMKVObject, setMMKVObject } from '@utils/mmkv/mmkv';
@@ -15,7 +16,7 @@ import { useCallback } from 'react';
import { getString } from '@strings/translations';
export const AVAILABLE_PLUGINS = 'AVAILABLE_PLUGINS';
export const INSTALLED_PLUGINS = 'INSTALL_PLUGINS';
export const INSTALLED_PLUGINS = INSTALLED_PLUGINS_KEY;
export const LANGUAGES_FILTER = 'LANGUAGES_FILTER';
export const LAST_USED_PLUGIN = 'LAST_USED_PLUGIN';
export const PINNED_PLUGINS = 'PINNED_PLUGINS';
+2 -2
View File
@@ -35,7 +35,7 @@ import { RootStackParamList } from './types';
import Color from 'color';
import { useMMKVBoolean } from 'react-native-mmkv';
import OnboardingScreen from '@screens/onboarding/OnboardingScreen';
import ServiceManager from '@services/ServiceManager';
import { backgroundTasks } from '@services/backgroundTasks';
import ReaderStack from './ReaderStack';
import { LibraryContextProvider } from '@components/Context/LibraryContext';
import { UpdateContextProvider } from '@components/Context/UpdateContext';
@@ -60,7 +60,7 @@ const MainNavigator = () => {
useEffect(() => {
if (updateLibraryOnLaunch) {
ServiceManager.manager.addTask({ name: 'UPDATE_LIBRARY' });
backgroundTasks.enqueue({ name: 'UPDATE_LIBRARY' });
}
if (isOnboarded) {
// hack this helps app has enough time to initialize database;
+37 -19
View File
@@ -12,6 +12,7 @@ import { newer } from '@utils/compareVersion';
import NativeFile from '@specs/NativeFile';
import { showToast } from '@utils/showToast';
import { PLUGIN_STORAGE } from '@utils/Storages';
import { getMMKVObject } from '@utils/mmkv/mmkv';
import {
store,
@@ -85,6 +86,7 @@ const initPlugin = (pluginId: string, rawCode: string) => {
};
const plugins: Record<string, Plugin | undefined> = {};
export const INSTALLED_PLUGINS_KEY = 'INSTALL_PLUGINS';
const installPlugin = async (
_plugin: PluginItem,
@@ -103,21 +105,21 @@ const installPlugin = async (
// save plugin code;
const pluginDir = `${PLUGIN_STORAGE}/${plugin.id}`;
NativeFile.mkdir(pluginDir);
await NativeFile.mkdir(pluginDir);
const pluginPath = pluginDir + '/index.js';
const customJSPath = pluginDir + '/custom.js';
const customCSSPath = pluginDir + '/custom.css';
if (_plugin.customJS) {
await downloadFile(_plugin.customJS, customJSPath);
} else if (NativeFile.exists(customJSPath)) {
NativeFile.unlink(customJSPath);
} else if (await NativeFile.exists(customJSPath)) {
await NativeFile.unlink(customJSPath);
}
if (_plugin.customCSS) {
await downloadFile(_plugin.customCSS, customCSSPath);
} else if (NativeFile.exists(customCSSPath)) {
NativeFile.unlink(customCSSPath);
} else if (await NativeFile.exists(customCSSPath)) {
await NativeFile.unlink(customCSSPath);
}
NativeFile.writeFile(pluginPath, rawCode);
await NativeFile.writeFile(pluginPath, rawCode);
}
return currentPlugin;
};
@@ -130,8 +132,8 @@ const uninstallPlugin = async (_plugin: PluginItem) => {
}
});
const pluginFilePath = `${PLUGIN_STORAGE}/${_plugin.id}/index.js`;
if (NativeFile.exists(pluginFilePath)) {
NativeFile.unlink(pluginFilePath);
if (await NativeFile.exists(pluginFilePath)) {
await NativeFile.unlink(pluginFilePath);
}
};
@@ -163,24 +165,40 @@ const getPlugin = (pluginId: string) => {
return undefined;
}
if (!plugins[pluginId]) {
const filePath = `${PLUGIN_STORAGE}/${pluginId}/index.js`;
try {
const code = NativeFile.readFile(filePath);
const plugin = initPlugin(pluginId, code);
plugins[pluginId] = plugin;
} catch {
// file doesnt exist
return undefined;
}
}
return plugins[pluginId];
};
const loadPlugin = async (pluginId: string) => {
if (pluginId === LOCAL_PLUGIN_ID) {
return undefined;
}
if (plugins[pluginId]) {
return plugins[pluginId];
}
const filePath = `${PLUGIN_STORAGE}/${pluginId}/index.js`;
try {
const code = await NativeFile.readFile(filePath);
const plugin = initPlugin(pluginId, code);
plugins[pluginId] = plugin;
return plugin;
} catch {
return undefined;
}
};
const initializeInstalledPlugins = async () => {
const installedPlugins =
getMMKVObject<PluginItem[]>(INSTALLED_PLUGINS_KEY) || [];
await Promise.all(installedPlugins.map(plugin => loadPlugin(plugin.id)));
};
const LOCAL_PLUGIN_ID = 'local';
export {
getPlugin,
loadPlugin,
initializeInstalledPlugins,
installPlugin,
uninstallPlugin,
updatePlugin,
@@ -11,7 +11,7 @@ import { NovelInfo } from '@database/types';
import { ThemeColors } from '@theme/types';
import { SourceSearchResult } from './MigrationNovels';
import { NovelItem } from '@plugins/types';
import ServiceManager from '@services/ServiceManager';
import { backgroundTasks } from '@services/backgroundTasks';
interface MigrationNovelListProps {
data: SourceSearchResult;
@@ -111,7 +111,7 @@ const MigrationNovelList = ({
<Button
onPress={() => {
hideMigrateNovelDialog();
ServiceManager.manager.addTask({
backgroundTasks.enqueue({
name: 'MIGRATE_NOVEL',
data: {
pluginId,
+3 -3
View File
@@ -44,7 +44,7 @@ import { Row } from '@components/Common';
import { LibraryScreenProps } from '@navigators/types';
import { NovelInfo } from '@database/types';
import * as DocumentPicker from 'expo-document-picker';
import ServiceManager from '@services/ServiceManager';
import { backgroundTasks } from '@services/backgroundTasks';
import useImport from '@hooks/persisted/useImport';
import { ThemeColors } from '@theme/types';
import { useLibraryContext } from '@components/Context/LibraryContext';
@@ -347,13 +347,13 @@ const LibraryScreen = ({ navigation }: LibraryScreenProps) => {
{
title: getString('libraryScreen.extraMenu.updateLibrary'),
onPress: () =>
ServiceManager.manager.addTask({ name: 'UPDATE_LIBRARY' }),
backgroundTasks.enqueue({ name: 'UPDATE_LIBRARY' }),
},
{
title: getString('libraryScreen.extraMenu.updateCategory'),
onPress: () =>
categories[index]?.id !== 2 &&
ServiceManager.manager.addTask({
backgroundTasks.enqueue({
name: 'UPDATE_LIBRARY',
data: {
categoryId: categories[index].id,
@@ -10,7 +10,7 @@ import { NovelInfo } from '@database/types';
import { getString } from '@strings/translations';
import { useTheme } from '@hooks/persisted';
import { LibraryScreenProps } from '@navigators/types';
import ServiceManager from '@services/ServiceManager';
import { backgroundTasks } from '@services/backgroundTasks';
import { getPlugin } from '@plugins/pluginManager';
import { useSelectionContext } from '../SelectionContext';
import { ImageRequestInit } from '@plugins/types';
@@ -80,7 +80,7 @@ export const LibraryView: React.FC<Props> = ({
if (categoryId === 2) {
return;
}
ServiceManager.manager.addTask({
backgroundTasks.enqueue({
name: 'UPDATE_LIBRARY',
data: { categoryId, categoryName },
});
+4 -3
View File
@@ -9,10 +9,11 @@ import { Category, NovelInfo } from '@database/types';
import { useLibrarySettings } from '@hooks/persisted';
import { LibrarySortOrder } from '../constants/constants';
import { switchNovelToLibraryQuery } from '@database/queries/NovelQueries';
import ServiceManager, {
import {
BACKGROUND_TASKS_STORE_KEY,
BackgroundTask,
QueuedBackgroundTask,
} from '@services/ServiceManager';
} from '@services/backgroundTasks';
import { useMMKVObject } from 'react-native-mmkv';
// type Library = Category & { novels: LibraryNovelInfo[] };
@@ -118,7 +119,7 @@ export const useLibrary = (): UseLibraryReturnType => {
const [taskQueue] = useMMKVObject<
Array<BackgroundTask | QueuedBackgroundTask>
>(ServiceManager.manager.STORE_KEY);
>(BACKGROUND_TASKS_STORE_KEY);
const restoreTasksCount = useMemo(
() =>
taskQueue?.filter(t => {
+6 -3
View File
@@ -9,12 +9,15 @@ import { useLibrarySettings, useTheme } from '@hooks/persisted';
import { MoreStackScreenProps } from '@navigators/types';
import Switch from '@components/Switch/Switch';
import { useMMKVObject } from 'react-native-mmkv';
import ServiceManager, { BackgroundTask } from '@services/ServiceManager';
import {
BACKGROUND_TASKS_STORE_KEY,
QueuedBackgroundTask,
} from '@services/backgroundTasks';
const MoreScreen = ({ navigation }: MoreStackScreenProps) => {
const theme = useTheme();
const [taskQueue] = useMMKVObject<BackgroundTask[]>(
ServiceManager.manager.STORE_KEY,
const [taskQueue] = useMMKVObject<QueuedBackgroundTask[]>(
BACKGROUND_TASKS_STORE_KEY,
);
const {
incognitoMode = false,
+10 -6
View File
@@ -13,7 +13,11 @@ import { showToast } from '../../utils/showToast';
import { getString } from '@strings/translations';
import { Appbar, EmptyView, Menu, SafeAreaView } from '@components';
import { TaskQueueScreenProps } from '@navigators/types';
import ServiceManager, { QueuedBackgroundTask } from '@services/ServiceManager';
import {
BACKGROUND_TASKS_STORE_KEY,
backgroundTasks,
QueuedBackgroundTask,
} from '@services/backgroundTasks';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useMMKVObject } from 'react-native-mmkv';
@@ -21,9 +25,9 @@ const DownloadQueue = ({ navigation }: TaskQueueScreenProps) => {
const theme = useTheme();
const { bottom, right } = useSafeAreaInsets();
const [taskQueue] = useMMKVObject<QueuedBackgroundTask[]>(
ServiceManager.manager.STORE_KEY,
BACKGROUND_TASKS_STORE_KEY,
);
const [isRunning, setIsRunning] = useState(ServiceManager.manager.isRunning);
const [isRunning, setIsRunning] = useState(backgroundTasks.isRunning);
const [visible, setVisible] = useState(false);
const openMenu = () => setVisible(true);
const closeMenu = () => setVisible(false);
@@ -58,7 +62,7 @@ const DownloadQueue = ({ navigation }: TaskQueueScreenProps) => {
>
<Menu.Item
onPress={() => {
ServiceManager.manager.stop();
backgroundTasks.cancelAll();
setIsRunning(false);
showToast(getString('downloadScreen.cancelled'));
closeMenu();
@@ -113,10 +117,10 @@ const DownloadQueue = ({ navigation }: TaskQueueScreenProps) => {
icon={isRunning ? 'pause' : 'play'}
onPress={() => {
if (isRunning) {
ServiceManager.manager.pause();
backgroundTasks.pauseAll();
setIsRunning(false);
} else {
ServiceManager.manager.resume();
backgroundTasks.resumeAll();
setIsRunning(true);
}
}}
@@ -156,8 +156,8 @@ const ExportNovelAsEpubButton: React.FC<ExportNovelAsEpubButtonProps> = ({
const chapter = chapters[i];
const chapterFilePath = `${NOVEL_STORAGE}/${novel.pluginId}/${novel.id}/${chapter.id}/index.html`;
if (NativeFile.exists(chapterFilePath)) {
let chapterContent = NativeFile.readFile(chapterFilePath);
if (await NativeFile.exists(chapterFilePath)) {
let chapterContent = await NativeFile.readFile(chapterFilePath);
const chapterDir = `${NOVEL_STORAGE}/${novel.pluginId}/${novel.id}/${chapter.id}`;
const escapedDir = chapterDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -168,7 +168,7 @@ const ExportNovelAsEpubButton: React.FC<ExportNovelAsEpubButtonProps> = ({
for (const match of chapterContent.matchAll(imagePathRegex)) {
const imagePath = match[1];
if (imagePath && !NativeFile.exists(imagePath)) {
if (imagePath && !(await NativeFile.exists(imagePath))) {
const escapedPath = imagePath.replace(
/[.*+?^${}()|[\]\\]/g,
'\\$&',
@@ -103,7 +103,8 @@ const NovelScreenList = ({
const theme = useTheme();
const { top: topInset, bottom: bottomInset } = useSafeAreaInsets();
const { downloadingChapterIds, downloadChapter } = useDownload();
const { downloadingChapterIds, downloadChapter, enqueueTasks } =
useDownload();
// Mark chapters as downloaded when their download completes
const prevDownloadingRef = useRef(downloadingChapterIds);
@@ -245,6 +246,7 @@ const NovelScreenList = ({
updateNovel(pluginId, novel.path, novel.id, {
downloadNewChapters,
refreshNovelMetadata,
enqueue: enqueueTasks,
})
.then(() => refreshNovel())
.then(() =>
@@ -261,6 +263,7 @@ const NovelScreenList = ({
downloadNewChapters,
refreshNovelMetadata,
refreshNovel,
enqueueTasks,
]);
const onRefreshPage = useCallback(
@@ -269,6 +272,7 @@ const NovelScreenList = ({
setUpdating(true);
updateNovelPage(pluginId, novel.path, novel.path, novel.id, page, {
downloadNewChapters,
enqueue: enqueueTasks,
})
.then(() => refreshNovel())
.then(() => showToast(`Updated page: ${page}`))
@@ -276,7 +280,7 @@ const NovelScreenList = ({
.finally(() => setUpdating(false));
}
},
[novel, pluginId, downloadNewChapters, refreshNovel],
[novel, pluginId, downloadNewChapters, refreshNovel, enqueueTasks],
);
const refreshControlElement = useMemo(
+2 -2
View File
@@ -109,8 +109,8 @@ export default function useChapter(
async (id: number, path: string) => {
const filePath = `${NOVEL_STORAGE}/${novel.pluginId}/${chapter.novelId}/${id}/index.html`;
let text = '';
if (NativeFile.exists(filePath)) {
text = NativeFile.readFile(filePath);
if (await NativeFile.exists(filePath)) {
text = await NativeFile.readFile(filePath);
} else {
await fetchChapter(novel.pluginId, path)
.then(res => {
@@ -11,7 +11,7 @@ import { getString } from '@strings/translations';
import { exists, getBackups, makeDir } from '@api/drive';
import { DriveFile } from '@api/drive/types';
import dayjs from 'dayjs';
import ServiceManager from '@services/ServiceManager';
import { backgroundTasks } from '@services/backgroundTasks';
enum BackupModal {
UNAUTHORIZED,
@@ -132,7 +132,7 @@ function CreateBackup({
onPress={() => {
prepare().then(folder => {
closeModal();
ServiceManager.manager.addTask({
backgroundTasks.enqueue({
name: 'DRIVE_BACKUP',
data: folder,
});
@@ -187,7 +187,7 @@ function RestoreBackup({
style={styles.btnOutline}
onPress={() => {
closeModal();
ServiceManager.manager.addTask({
backgroundTasks.enqueue({
name: 'DRIVE_RESTORE',
data: item,
});
@@ -1,7 +1,7 @@
import { list } from '@api/remote';
import { Button, EmptyView, Modal } from '@components';
import { useSelfHost } from '@hooks/persisted/useSelfHost';
import ServiceManager from '@services/ServiceManager';
import { backgroundTasks } from '@services/backgroundTasks';
import { getString } from '@strings/translations';
import { ThemeColors } from '@theme/types';
import { fetchTimeout } from '@utils/fetch/fetch';
@@ -53,7 +53,7 @@ function CreateBackup({
title={getString('common.ok')}
onPress={() => {
closeModal();
ServiceManager.manager.addTask({
backgroundTasks.enqueue({
name: 'SELF_HOST_BACKUP',
data: {
host,
@@ -110,7 +110,7 @@ function RestoreBackup({
style={styles.btnOutline}
onPress={() => {
closeModal();
ServiceManager.manager.addTask({
backgroundTasks.enqueue({
name: 'SELF_HOST_RESTORE',
data: {
host,
@@ -5,10 +5,12 @@ import { useBoolean } from '@hooks';
import { BackupSettingsScreenProps } from '@navigators/types';
import GoogleDriveModal from './Components/GoogleDriveModal';
import SelfHostModal from './Components/SelfHostModal';
import ServiceManager from '@services/ServiceManager';
import { backgroundTasks } from '@services/backgroundTasks';
import { ScrollView } from 'react-native-gesture-handler';
import { getString } from '@strings/translations';
import { StyleSheet } from 'react-native';
import dayjs from 'dayjs';
import NativeFile from '@specs/NativeFile';
const BackupSettings = ({ navigation }: BackupSettingsScreenProps) => {
const theme = useTheme();
@@ -18,6 +20,36 @@ const BackupSettings = ({ navigation }: BackupSettingsScreenProps) => {
setTrue: openGoogleDriveModal,
} = useBoolean();
const createLocalBackup = async () => {
try {
const filename = `lnreader_backup_${dayjs().format(
'YYYY-MM-DD_HH_mm',
)}.zip`;
const destinationUri = await NativeFile.createDocument(
filename,
'application/zip',
);
backgroundTasks.enqueue({
name: 'LOCAL_BACKUP',
data: { destinationUri },
});
} catch {
// Closing Android's document picker intentionally leaves the queue unchanged.
}
};
const restoreLocalBackup = async () => {
try {
const sourceUri = await NativeFile.pickDocument('application/zip');
backgroundTasks.enqueue({
name: 'LOCAL_RESTORE',
data: { sourceUri },
});
} catch {
// Closing Android's document picker intentionally leaves the queue unchanged.
}
};
const {
value: selfHostModalVisible,
setFalse: closeSelfHostModal,
@@ -55,17 +87,13 @@ const BackupSettings = ({ navigation }: BackupSettingsScreenProps) => {
<List.Item
title={getString('backupScreen.createBackup')}
description={getString('backupScreen.createBackupDesc')}
onPress={() => {
ServiceManager.manager.addTask({ name: 'LOCAL_BACKUP' });
}}
onPress={createLocalBackup}
theme={theme}
/>
<List.Item
title={getString('backupScreen.restoreBackup')}
description={getString('backupScreen.restoreBackupDesc')}
onPress={() => {
ServiceManager.manager.addTask({ name: 'LOCAL_RESTORE' });
}}
onPress={restoreLocalBackup}
theme={theme}
/>
<List.InfoItem
@@ -114,9 +114,9 @@ if (title) {
NativeFile.getConstants().ExternalCachesDirectoryPath +
'/imported_custom.' +
activeCodeTab;
NativeFile.copyFile(file.assets[0].uri, tempPath);
const content = NativeFile.readFile(tempPath);
NativeFile.unlink(tempPath);
await NativeFile.copyFile(file.assets[0].uri, tempPath);
const content = await NativeFile.readFile(tempPath);
await NativeFile.unlink(tempPath);
if (activeCodeTab === 'css') {
setCssValue(content.trim());
+3 -3
View File
@@ -17,7 +17,7 @@ import UpdatesSkeletonLoading from './components/UpdatesSkeletonLoading';
import UpdateNovelCard from './components/UpdateNovelCard';
import { deleteChapter } from '@database/queries/ChapterQueries';
import { showToast } from '@utils/showToast';
import ServiceManager from '@services/ServiceManager';
import { backgroundTasks } from '@services/backgroundTasks';
import { UpdateScreenProps } from '@navigators/types';
import { UpdateOverview } from '@database/types';
import { useUpdateContext } from '@components/Context/UpdateContext';
@@ -63,7 +63,7 @@ const UpdatesScreen = ({ navigation }: UpdateScreenProps) => {
{
iconName: 'reload',
onPress: () =>
ServiceManager.manager.addTask({ name: 'UPDATE_LIBRARY' }),
backgroundTasks.enqueue({ name: 'UPDATE_LIBRARY' }),
},
]}
/>
@@ -140,7 +140,7 @@ const UpdatesScreen = ({ navigation }: UpdateScreenProps) => {
<RefreshControl
refreshing={false}
onRefresh={() =>
ServiceManager.manager.addTask({ name: 'UPDATE_LIBRARY' })
backgroundTasks.enqueue({ name: 'UPDATE_LIBRARY' })
}
colors={[theme.onPrimary]}
progressBackgroundColor={theme.primary}
-475
View File
@@ -1,475 +0,0 @@
import BackgroundService from 'react-native-background-actions';
import * as Notifications from 'expo-notifications';
import { getMMKVObject, setMMKVObject } from '@utils/mmkv/mmkv';
import { importEpub } from './epub/import';
import { getString } from '@strings/translations';
import { updateLibrary } from './updates';
import { DriveFile } from '@api/drive/types';
import { createDriveBackup, driveRestore } from './backup/drive';
import {
createSelfHostBackup,
SelfHostData,
selfHostRestore,
} from './backup/selfhost';
import { createBackup, restoreBackup } from './backup/local';
import { migrateNovel, MigrateNovelData } from './migrate/migrateNovel';
import { downloadChapter } from './download/downloadChapter';
import { askForPostNotificationsPermission } from '@utils/askForPostNoftificationsPermission';
type taskNames =
| 'IMPORT_EPUB'
| 'UPDATE_LIBRARY'
| 'DRIVE_BACKUP'
| 'DRIVE_RESTORE'
| 'SELF_HOST_BACKUP'
| 'SELF_HOST_RESTORE'
| 'LOCAL_BACKUP'
| 'LOCAL_RESTORE'
| 'MIGRATE_NOVEL'
| 'DOWNLOAD_CHAPTER';
export type BackgroundTask =
| {
name: 'IMPORT_EPUB';
data: {
filename: string;
uri: string;
};
}
| {
name: 'UPDATE_LIBRARY';
data?: {
categoryId?: number;
categoryName?: string;
};
}
| { name: 'DRIVE_BACKUP'; data: DriveFile }
| { name: 'DRIVE_RESTORE'; data: DriveFile }
| { name: 'SELF_HOST_BACKUP'; data: SelfHostData }
| { name: 'SELF_HOST_RESTORE'; data: SelfHostData }
| { name: 'LOCAL_BACKUP' }
| { name: 'LOCAL_RESTORE' }
| { name: 'MIGRATE_NOVEL'; data: MigrateNovelData }
| DownloadChapterTask;
export type DownloadChapterTask = {
name: 'DOWNLOAD_CHAPTER';
data: { chapterId: number; novelName: string; chapterName: string };
};
export type BackgroundTaskMetadata = {
name: string;
isRunning: boolean;
progress: number | undefined;
progressText: string | undefined;
};
export type QueuedBackgroundTask = {
task: BackgroundTask;
meta: BackgroundTaskMetadata;
id: string;
};
function makeId() {
return (
Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15)
);
}
export default class ServiceManager {
STORE_KEY = 'APP_SERVICE';
lastNotifUpdate = 0;
currentPendingUpdate = 0;
private static instance?: ServiceManager;
private constructor() {}
static get manager() {
if (!this.instance) {
this.instance = new ServiceManager();
}
return this.instance;
}
get isRunning() {
return BackgroundService.isRunning();
}
isMultiplicableTask(task: BackgroundTask) {
if (!task?.name) {
return false;
}
return (
['DOWNLOAD_CHAPTER', 'IMPORT_EPUB', 'MIGRATE_NOVEL'] as Array<
BackgroundTask['name']
>
).includes(task.name);
}
async start() {
if (!this.isRunning) {
const notificationsAllowed = await askForPostNotificationsPermission();
if (!notificationsAllowed) return;
BackgroundService.start(ServiceManager.launch, {
taskName: 'app_services',
taskTitle: 'App Service',
taskDesc: getString('common.preparing'),
taskIcon: { name: 'notification_icon', type: 'drawable' },
color: '#00adb5',
linkingURI: 'lnreader://',
}).catch(error => {
Notifications.scheduleNotificationAsync({
content: {
title: getString('backupScreen.drive.backupInterruped'),
body: error.message,
},
trigger: null,
});
BackgroundService.stop();
});
}
}
setMeta(
transformer: (meta: BackgroundTaskMetadata) => BackgroundTaskMetadata,
) {
const taskList = [...this.getTaskList()];
if (taskList.length === 0 || !taskList[0]?.meta) {
return;
}
taskList[0] = {
...taskList[0],
meta: transformer(taskList[0].meta),
};
if (
taskList[0].meta?.isRunning &&
taskList[0].task?.name !== 'DOWNLOAD_CHAPTER'
) {
const now = Date.now();
if (now - this.lastNotifUpdate < 1000) {
const delay = 1000 - (now - this.lastNotifUpdate);
const id = ++this.currentPendingUpdate;
setTimeout(() => {
if (this.currentPendingUpdate !== id) {
return;
}
BackgroundService.updateNotification({
taskTitle: taskList[0].meta?.name || 'Unknown Task',
taskDesc: taskList[0].meta?.progressText ?? '',
progressBar: {
indeterminate: taskList[0].meta?.progress === undefined,
value: (taskList[0].meta?.progress || 0) * 100,
max: 100,
},
});
}, delay);
} else {
this.lastNotifUpdate = now;
BackgroundService.updateNotification({
taskTitle: taskList[0].meta?.name || 'Unknown Task',
taskDesc: taskList[0].meta?.progressText ?? '',
progressBar: {
indeterminate: taskList[0].meta?.progress === undefined,
value: (taskList[0].meta?.progress || 0) * 100,
max: 100,
},
});
}
}
setMMKVObject(this.STORE_KEY, taskList);
}
//gets the progress bar for download chapters notification
getProgressForNotification(
currentTask: QueuedBackgroundTask,
startingTasks: QueuedBackgroundTask[],
) {
let i = null;
let count = 0;
for (const task of startingTasks) {
if (
task.task?.name === 'DOWNLOAD_CHAPTER' &&
task.meta?.name === currentTask.meta?.name
) {
if (task.id === currentTask.id) {
i = count;
}
count++;
} else {
if (i !== null) {
break;
}
count = 0;
}
}
if (i === null) {
return null;
}
return (i / count) * 100;
}
async executeTask(
task: QueuedBackgroundTask,
startingTasks: QueuedBackgroundTask[],
) {
// Safety check for old format tasks
if (!task?.task?.name) {
return;
}
const progress =
task.task.name === 'DOWNLOAD_CHAPTER'
? this.getProgressForNotification(task, startingTasks)
: null;
await BackgroundService.updateNotification({
taskTitle: task.meta?.name || 'Unknown Task',
taskDesc: task.meta?.progressText ?? '',
progressBar: {
indeterminate: progress === null,
max: 100,
value: progress == null ? 0 : progress,
},
});
this.lastNotifUpdate = Date.now();
this.currentPendingUpdate = 0;
switch (task.task.name) {
case 'IMPORT_EPUB':
return importEpub(task.task.data, this.setMeta.bind(this));
case 'UPDATE_LIBRARY':
return updateLibrary(task.task.data || {}, this.setMeta.bind(this));
case 'DRIVE_BACKUP':
return createDriveBackup(task.task.data, this.setMeta.bind(this));
case 'DRIVE_RESTORE':
return driveRestore(task.task.data, this.setMeta.bind(this));
case 'SELF_HOST_BACKUP':
return createSelfHostBackup(task.task.data, this.setMeta.bind(this));
case 'SELF_HOST_RESTORE':
return selfHostRestore(task.task.data, this.setMeta.bind(this));
case 'LOCAL_BACKUP':
return createBackup(this.setMeta.bind(this));
case 'LOCAL_RESTORE':
return restoreBackup(this.setMeta.bind(this));
case 'MIGRATE_NOVEL':
return migrateNovel(task.task.data, this.setMeta.bind(this));
case 'DOWNLOAD_CHAPTER':
return downloadChapter(task.task.data, this.setMeta.bind(this));
}
}
static async launch() {
// retrieve class instance because this is running in different context
const manager = ServiceManager.manager;
const doneTasks: Record<BackgroundTask['name'], number> = {
'IMPORT_EPUB': 0,
'UPDATE_LIBRARY': 0,
'DRIVE_BACKUP': 0,
'DRIVE_RESTORE': 0,
'SELF_HOST_BACKUP': 0,
'SELF_HOST_RESTORE': 0,
'LOCAL_BACKUP': 0,
'LOCAL_RESTORE': 0,
'MIGRATE_NOVEL': 0,
'DOWNLOAD_CHAPTER': 0,
};
const startingTasks = manager.getTaskList();
const tasksSet = new Set(startingTasks.map(t => t.id));
while (BackgroundService.isRunning()) {
const currentTasks = manager.getTaskList();
const currentTask = currentTasks[0];
if (!currentTask) {
break;
}
//Add any newly queued tasks to the starting tasks list
const newtasks = currentTasks.filter(t => !tasksSet.has(t.id));
startingTasks.push(...newtasks);
newtasks.forEach(t => tasksSet.add(t.id));
try {
// Safety check - getTaskList() should already handle conversion, but double-check
if (!currentTask?.task?.name) {
// Skip invalid tasks
setMMKVObject(manager.STORE_KEY, manager.getTaskList().slice(1));
continue;
}
await manager.executeTask(currentTask, startingTasks);
doneTasks[currentTask.task.name] += 1;
} catch (error: any) {
await Notifications.scheduleNotificationAsync({
content: {
title: currentTask.meta?.name || 'Task Error',
body: error?.message || String(error),
},
trigger: null,
});
} finally {
setMMKVObject(manager.STORE_KEY, manager.getTaskList().slice(1));
}
}
if (manager.getTaskList().length === 0) {
await Notifications.scheduleNotificationAsync({
content: {
title: getString('common.done'),
body: Object.keys(doneTasks)
.filter(key => doneTasks[key as BackgroundTask['name']] > 0)
.map(
key =>
`${getString(`notifications.${key as taskNames}`)}: ${
doneTasks[key as BackgroundTask['name']]
}`,
)
.join('\n'),
},
trigger: null,
});
}
}
getTaskName(task: BackgroundTask) {
if (!task?.name) {
return 'Unknown Task';
}
switch (task.name) {
case 'DOWNLOAD_CHAPTER':
return `${getString('notifications.DOWNLOAD_CHAPTER')}: ${
task.data?.novelName || ''
}`;
case 'IMPORT_EPUB':
return `${getString('notifications.IMPORT_EPUB')}: ${
task.data?.filename || ''
}`;
case 'MIGRATE_NOVEL':
return `${getString('notifications.MIGRATE_NOVEL')}: ${
task.data?.fromNovel?.name || ''
}`;
case 'UPDATE_LIBRARY':
if (task.data !== undefined) {
return `${getString('notifications.UPDATE_LIBRARY')}: ${
task.data?.categoryName || ''
}`;
}
return getString('notifications.UPDATE_LIBRARY');
case 'DRIVE_BACKUP':
return getString('notifications.DRIVE_BACKUP');
case 'DRIVE_RESTORE':
return getString('notifications.DRIVE_RESTORE');
case 'SELF_HOST_BACKUP':
return getString('notifications.SELF_HOST_BACKUP');
case 'SELF_HOST_RESTORE':
return getString('notifications.SELF_HOST_RESTORE');
case 'LOCAL_BACKUP':
return getString('notifications.LOCAL_BACKUP');
case 'LOCAL_RESTORE':
return getString('notifications.LOCAL_RESTORE');
default:
return 'Unknown Task';
}
}
getTaskList() {
const tasks = getMMKVObject<Array<any>>(this.STORE_KEY) || [];
const convertedTasks = tasks
.map(task => {
if (task?.task && task?.meta && task?.id) {
return task as QueuedBackgroundTask;
}
if (task?.name && !task?.task) {
const backgroundTask = task as BackgroundTask;
return {
task: backgroundTask,
meta: {
name: this.getTaskName(backgroundTask),
isRunning: false,
progress: undefined,
progressText:
backgroundTask.name === 'DOWNLOAD_CHAPTER'
? (backgroundTask as DownloadChapterTask).data?.chapterName
: undefined,
},
id: makeId(),
} as QueuedBackgroundTask;
}
return null;
})
.filter((task): task is QueuedBackgroundTask => task !== null);
const hasOldFormat = tasks.some(task => task?.name && !task?.task);
if (hasOldFormat) {
setMMKVObject(this.STORE_KEY, convertedTasks);
}
return convertedTasks;
}
addTask(tasks: BackgroundTask | BackgroundTask[]) {
const currentTasks = this.getTaskList();
const addableTasks = (Array.isArray(tasks) ? tasks : [tasks]).filter(
task =>
this.isMultiplicableTask(task) ||
!currentTasks.some(_t => _t.task?.name === task.name),
);
if (addableTasks.length) {
const newTasks: QueuedBackgroundTask[] = addableTasks.map(task => ({
task,
meta: {
name: this.getTaskName(task),
isRunning: false,
progress: undefined,
progressText:
task.name === 'DOWNLOAD_CHAPTER'
? task.data?.chapterName
: undefined,
},
id: makeId(),
}));
setMMKVObject(this.STORE_KEY, currentTasks.concat(newTasks));
this.start();
}
}
removeTasksByName(name: BackgroundTask['name']) {
const taskList = this.getTaskList();
if (taskList[0]?.task?.name === name) {
this.pause();
setMMKVObject(
this.STORE_KEY,
taskList.filter(t => t.task?.name !== name),
);
this.resume();
} else {
setMMKVObject(
this.STORE_KEY,
taskList.filter(t => t.task?.name !== name),
);
}
}
clearTaskList() {
setMMKVObject(this.STORE_KEY, []);
}
pause() {
BackgroundService.stop();
}
resume() {
this.start();
}
stop() {
BackgroundService.stop();
this.clearTaskList();
}
}
@@ -0,0 +1,235 @@
import { DeviceEventEmitter } from 'react-native';
import NativeBackgroundTasks from '@specs/NativeBackgroundTasks';
import { getString } from '@strings/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,
} from './taskDefinitions';
export const BACKGROUND_TASKS_STORE_KEY = 'APP_SERVICE';
const makeTemporaryId = () =>
`pending-${Date.now()}-${Math.random().toString(36).slice(2)}`;
export class BackgroundTaskQueue {
private currentTaskId?: string;
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).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) {
this.currentTaskId = taskId;
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,
this.updateProgress.bind(this),
this.enqueue,
{
checkpoint,
updateCheckpoint: value => {
if (this.interruptedTasks.get(taskId) === 'cancel') {
this.throwIfInterrupted(taskId);
}
return NativeBackgroundTasks.updateCheckpoint(taskId, value);
},
},
);
this.throwIfInterrupted(taskId);
await NativeBackgroundTasks.complete(taskId);
} catch (error) {
await NativeBackgroundTasks.fail(taskId, String(error), false);
if (!this.interruptedTasks.has(taskId)) {
throw error;
}
} finally {
this.finishLocalExecution(taskId);
}
}
private async enqueueOne(task: BackgroundTask) {
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),
};
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),
);
const latest = this.getSnapshot().filter(item => item.id !== pending.id);
if (!latest.some(item => item.id === id)) {
latest.push({ ...pending, id });
}
this.store(latest);
} 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(
transformer: (meta: BackgroundTaskMetadata) => BackgroundTaskMetadata,
) {
const taskId = this.currentTaskId;
if (!taskId) return;
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.currentTaskId = undefined;
this.interruptedTasks.delete(taskId);
}
private store(tasks: QueuedBackgroundTask[]) {
setMMKVObject(BACKGROUND_TASKS_STORE_KEY, tasks);
}
}
@@ -0,0 +1,93 @@
import type { NativeBackgroundTaskRecord } from '@specs/NativeBackgroundTasks';
import type { BackgroundTask } from '../contracts';
import {
allowsDuplicateTask,
createBackgroundTaskMetadata,
fromNativeTaskRecord,
getBackgroundTaskTitle,
} from '../taskDefinitions';
jest.mock('@strings/translations', () => ({
getString: (key: string) => key,
}));
describe('background task definitions', () => {
it.each<BackgroundTask['name']>([
'DOWNLOAD_CHAPTER',
'IMPORT_EPUB',
'MIGRATE_NOVEL',
])('allows duplicate %s tasks', name => {
expect(allowsDuplicateTask({ name } as BackgroundTask)).toBe(true);
});
it('prevents duplicate singleton task types', () => {
expect(
allowsDuplicateTask({ name: 'UPDATE_LIBRARY' } as BackgroundTask),
).toBe(false);
});
it('derives user-facing download metadata', () => {
const task: BackgroundTask = {
name: 'DOWNLOAD_CHAPTER',
data: {
novelName: 'Example Novel',
chapters: [{ chapterId: 42, chapterName: 'Chapter 7' }],
},
};
expect(getBackgroundTaskTitle(task)).toBe(
'notifications.DOWNLOAD_CHAPTER: Example Novel',
);
expect(createBackgroundTaskMetadata(task, false)).toEqual({
name: 'notifications.DOWNLOAD_CHAPTER: Example Novel',
isRunning: false,
progress: undefined,
progressText: 'Chapter 7',
});
});
it('derives metadata for a multi-file EPUB import', () => {
const task: BackgroundTask = {
name: 'IMPORT_EPUB',
data: {
files: [
{ filename: 'First.epub', uri: 'file://first' },
{ filename: 'Second.epub', uri: 'file://second' },
],
},
};
expect(getBackgroundTaskTitle(task)).toBe('notifications.IMPORT_EPUB (2)');
expect(createBackgroundTaskMetadata(task, false).progressText).toBe(
'First.epub',
);
});
it('maps a native record into the reactive queue projection', () => {
const task: BackgroundTask = { name: 'UPDATE_LIBRARY' };
const record: NativeBackgroundTaskRecord = {
id: 'task-1',
type: task.name,
payload: JSON.stringify(task),
title: 'Update library',
state: 'running',
progress: 0.5,
progressText: 'Example Novel',
attempt: 1,
createdAt: 1,
updatedAt: 2,
};
expect(fromNativeTaskRecord(record)).toEqual({
id: 'task-1',
task,
state: 'running',
meta: {
name: 'Update library',
isRunning: true,
progress: 0.5,
progressText: 'Example Novel',
},
});
});
});
@@ -0,0 +1,3 @@
import { BackgroundTaskQueue } from './BackgroundTaskQueue';
export const backgroundTasks = new BackgroundTaskQueue();
+78
View File
@@ -0,0 +1,78 @@
import type { DriveFile } from '@api/drive/types';
import type { NovelInfo } from '@database/types';
import type { NativeBackgroundTaskRecord } from '@specs/NativeBackgroundTasks';
export type SelfHostData = {
host: string;
backupFolder: string;
};
export type MigrateNovelData = {
pluginId: string;
fromNovel: NovelInfo;
toNovelPath: string;
};
export type EpubImportFile = {
filename: string;
uri: string;
};
export type ChapterDownload = {
chapterId: number;
chapterName: string;
};
export type BackgroundTask =
| { name: 'IMPORT_EPUB'; data: { files: EpubImportFile[] } }
| {
name: 'UPDATE_LIBRARY';
data?: { categoryId?: number; categoryName?: string };
}
| { name: 'DRIVE_BACKUP'; data: DriveFile }
| { name: 'DRIVE_RESTORE'; data: DriveFile }
| { name: 'SELF_HOST_BACKUP'; data: SelfHostData }
| { name: 'SELF_HOST_RESTORE'; data: SelfHostData }
| { name: 'LOCAL_BACKUP'; data: { destinationUri: string } }
| { name: 'LOCAL_RESTORE'; data: { sourceUri: string } }
| { name: 'MIGRATE_NOVEL'; data: MigrateNovelData }
| DownloadChapterTask;
export type DownloadChapterTask = {
name: 'DOWNLOAD_CHAPTER';
data: { novelName: string; chapters: ChapterDownload[] };
};
export type BackgroundTaskMetadata = {
name: string;
isRunning: boolean;
progress: number | undefined;
progressText: string | undefined;
};
export type TaskProgressUpdater = (
transformer: (meta: BackgroundTaskMetadata) => BackgroundTaskMetadata,
) => void;
export type BackgroundTaskExecutionContext = {
checkpoint?: string;
updateCheckpoint: (checkpoint: string) => Promise<void>;
};
export type BackgroundTaskEnqueuer = (
tasks: BackgroundTask | BackgroundTask[],
) => void;
export type QueuedBackgroundTask = {
task: BackgroundTask;
meta: BackgroundTaskMetadata;
id: string;
state?: NativeBackgroundTaskRecord['state'];
};
export type HeadlessBackgroundTaskData = {
taskId: string;
type: BackgroundTask['name'];
payload: string;
checkpoint?: string;
};
@@ -0,0 +1,43 @@
import { createDriveBackup, driveRestore } from '../backup/drive';
import { createBackup, restoreBackup } from '../backup/local';
import { createSelfHostBackup, selfHostRestore } from '../backup/selfhost';
import { downloadChapters } from '../download/downloadChapter';
import { importEpubBatch } from '../epub/import';
import { migrateNovel } from '../migrate/migrateNovel';
import { updateLibrary } from '../updates';
import type {
BackgroundTask,
BackgroundTaskEnqueuer,
BackgroundTaskExecutionContext,
TaskProgressUpdater,
} from './contracts';
export const executeBackgroundTask = async (
task: BackgroundTask,
updateProgress: TaskProgressUpdater,
enqueue: BackgroundTaskEnqueuer,
context: BackgroundTaskExecutionContext,
) => {
switch (task.name) {
case 'IMPORT_EPUB':
return importEpubBatch(task.data, updateProgress);
case 'UPDATE_LIBRARY':
return updateLibrary(task.data || {}, updateProgress, enqueue);
case 'DRIVE_BACKUP':
return createDriveBackup(task.data, updateProgress);
case 'DRIVE_RESTORE':
return driveRestore(task.data, updateProgress);
case 'SELF_HOST_BACKUP':
return createSelfHostBackup(task.data, updateProgress);
case 'SELF_HOST_RESTORE':
return selfHostRestore(task.data, updateProgress);
case 'LOCAL_BACKUP':
return createBackup(task.data, updateProgress);
case 'LOCAL_RESTORE':
return restoreBackup(task.data, updateProgress);
case 'MIGRATE_NOVEL':
return migrateNovel(task.data, updateProgress, enqueue);
case 'DOWNLOAD_CHAPTER':
return downloadChapters(task.data, updateProgress, context);
}
};
@@ -0,0 +1,18 @@
import { initializeDatabase } from '@database/db';
import { initializeInstalledPlugins } from '@plugins/pluginManager';
import type { BackgroundTask, HeadlessBackgroundTaskData } from './contracts';
import { backgroundTasks } from './backgroundTasks';
export const runHeadlessBackgroundTask = async ({
taskId,
payload,
checkpoint,
}: HeadlessBackgroundTaskData) => {
await initializeDatabase();
await initializeInstalledPlugins();
await backgroundTasks.run(
taskId,
JSON.parse(payload) as BackgroundTask,
checkpoint,
);
};
+17
View File
@@ -0,0 +1,17 @@
export { backgroundTasks } from './backgroundTasks';
export { BACKGROUND_TASKS_STORE_KEY } from './BackgroundTaskQueue';
export { runHeadlessBackgroundTask } from './headlessTask';
export type {
BackgroundTask,
BackgroundTaskEnqueuer,
BackgroundTaskExecutionContext,
BackgroundTaskMetadata,
ChapterDownload,
DownloadChapterTask,
EpubImportFile,
HeadlessBackgroundTaskData,
MigrateNovelData,
QueuedBackgroundTask,
SelfHostData,
TaskProgressUpdater,
} from './contracts';
@@ -0,0 +1,83 @@
import type { NativeBackgroundTaskRecord } from '@specs/NativeBackgroundTasks';
import { getString } from '@strings/translations';
import type {
BackgroundTask,
BackgroundTaskMetadata,
QueuedBackgroundTask,
} from './contracts';
export const ACTIVE_BACKGROUND_TASK_STATES = new Set([
'queued',
'running',
'paused',
]);
const MULTIPLICABLE_TASKS: BackgroundTask['name'][] = [
'DOWNLOAD_CHAPTER',
'IMPORT_EPUB',
'MIGRATE_NOVEL',
];
export const allowsDuplicateTask = (task: BackgroundTask) =>
MULTIPLICABLE_TASKS.includes(task.name);
export const getBackgroundTaskTitle = (task: BackgroundTask) => {
switch (task.name) {
case 'DOWNLOAD_CHAPTER':
return `${getString('notifications.DOWNLOAD_CHAPTER')}: ${
task.data.novelName
}`;
case 'IMPORT_EPUB':
return task.data.files.length === 1
? `${getString('notifications.IMPORT_EPUB')}: ${
task.data.files[0].filename
}`
: `${getString('notifications.IMPORT_EPUB')} (${
task.data.files.length
})`;
case 'MIGRATE_NOVEL':
return `${getString('notifications.MIGRATE_NOVEL')}: ${
task.data.fromNovel.name
}`;
case 'UPDATE_LIBRARY':
return task.data?.categoryName
? `${getString('notifications.UPDATE_LIBRARY')}: ${
task.data.categoryName
}`
: getString('notifications.UPDATE_LIBRARY');
default:
return getString(`notifications.${task.name}`);
}
};
export const createBackgroundTaskMetadata = (
task: BackgroundTask,
isRunning: boolean,
): BackgroundTaskMetadata => ({
name: getBackgroundTaskTitle(task),
isRunning,
progress: undefined,
progressText:
task.name === 'DOWNLOAD_CHAPTER'
? task.data.chapters[0]?.chapterName
: task.name === 'IMPORT_EPUB'
? task.data.files[0]?.filename
: undefined,
});
export const fromNativeTaskRecord = (
record: NativeBackgroundTaskRecord,
): QueuedBackgroundTask => {
const task = JSON.parse(record.payload) as BackgroundTask;
return {
id: record.id,
task,
state: record.state,
meta: {
name: record.title,
isRunning: record.state === 'running',
progress: record.progress,
progressText: record.progressText,
},
};
};
+3 -7
View File
@@ -6,13 +6,11 @@ import { CACHE_DIR_PATH, prepareBackupData, restoreData } from '../utils';
import { download, updateMetadata, uploadMedia } from '@api/drive/request';
import { ZipBackupName } from '../types';
import { ROOT_STORAGE } from '@utils/Storages';
import { BackgroundTaskMetadata } from '@services/ServiceManager';
import type { TaskProgressUpdater } from '@services/backgroundTasks/contracts';
export const createDriveBackup = async (
backupFolder: DriveFile,
setMeta: (
transformer: (meta: BackgroundTaskMetadata) => BackgroundTaskMetadata,
) => void,
setMeta: TaskProgressUpdater,
) => {
setMeta(meta => ({
...meta,
@@ -69,9 +67,7 @@ export const createDriveBackup = async (
export const driveRestore = async (
backupFolder: DriveFile,
setMeta: (
transformer: (meta: BackgroundTaskMetadata) => BackgroundTaskMetadata,
) => void,
setMeta: TaskProgressUpdater,
) => {
setMeta(meta => ({
...meta,
+12 -46
View File
@@ -1,24 +1,16 @@
import { showToast } from '@utils/showToast';
import dayjs from 'dayjs';
import {
saveDocuments,
pick,
types,
keepLocalCopy,
} from '@react-native-documents/picker';
import { CACHE_DIR_PATH, prepareBackupData, restoreData } from '../utils';
import NativeZipArchive from '@specs/NativeZipArchive';
import { ROOT_STORAGE } from '@utils/Storages';
import { ZipBackupName } from '../types';
import NativeFile from '@specs/NativeFile';
import { getString } from '@strings/translations';
import { BackgroundTaskMetadata } from '@services/ServiceManager';
import type { TaskProgressUpdater } from '@services/backgroundTasks/contracts';
import { sleep } from '@utils/sleep';
export const createBackup = async (
setMeta?: (
transformer: (meta: BackgroundTaskMetadata) => BackgroundTaskMetadata,
) => void,
{ destinationUri }: { destinationUri: string },
setMeta?: TaskProgressUpdater,
) => {
try {
setMeta?.(meta => ({
@@ -59,15 +51,7 @@ export const createBackup = async (
progressText: getString('backupScreen.savingBackup'),
}));
const datetime = dayjs().format('YYYY-MM-DD_HH_mm');
const fileName = 'lnreader_backup_' + datetime + '.zip';
await saveDocuments({
sourceUris: ['file://' + CACHE_DIR_PATH + '.zip'],
copy: false,
mimeType: 'application/zip',
fileName,
});
await NativeFile.copyFile(CACHE_DIR_PATH + '.zip', destinationUri);
setMeta?.(meta => ({
...meta,
@@ -82,13 +66,13 @@ export const createBackup = async (
isRunning: false,
}));
showToast(error.message);
throw error;
}
};
export const restoreBackup = async (
setMeta?: (
transformer: (meta: BackgroundTaskMetadata) => BackgroundTaskMetadata,
) => void,
{ sourceUri }: { sourceUri: string },
setMeta?: TaskProgressUpdater,
) => {
try {
setMeta?.(meta => ({
@@ -98,30 +82,11 @@ export const restoreBackup = async (
progressText: getString('backupScreen.downloadingData'),
}));
const [result] = await pick({
mode: 'import',
type: [types.zip],
allowVirtualFiles: true, // TODO: hopefully this just works
});
if (NativeFile.exists(CACHE_DIR_PATH)) {
NativeFile.unlink(CACHE_DIR_PATH);
if (await NativeFile.exists(CACHE_DIR_PATH)) {
await NativeFile.unlink(CACHE_DIR_PATH);
}
const [localRes] = await keepLocalCopy({
files: [
{
uri: result.uri,
fileName: 'backup.zip',
},
],
destination: 'cachesDirectory',
});
if (localRes.status === 'error') {
throw new Error(localRes.copyError);
}
const localPath = localRes.localUri.replace(/^file:(\/\/)?\//, '/');
const localPath = CACHE_DIR_PATH + '-source.zip';
await NativeFile.copyFile(sourceUri, localPath);
setMeta?.(meta => ({
...meta,
@@ -170,5 +135,6 @@ export const restoreBackup = async (
isRunning: false,
}));
showToast(error.message);
throw error;
}
};
+6 -12
View File
@@ -4,18 +4,14 @@ import { getString } from '@strings/translations';
import { CACHE_DIR_PATH, prepareBackupData, restoreData } from '../utils';
import { ZipBackupName } from '../types';
import { ROOT_STORAGE } from '@utils/Storages';
import { BackgroundTaskMetadata } from '@services/ServiceManager';
export interface SelfHostData {
host: string;
backupFolder: string;
}
import type {
SelfHostData,
TaskProgressUpdater,
} from '@services/backgroundTasks/contracts';
export const createSelfHostBackup = async (
{ host, backupFolder }: SelfHostData,
setMeta: (
transformer: (meta: BackgroundTaskMetadata) => BackgroundTaskMetadata,
) => void,
setMeta: TaskProgressUpdater,
) => {
setMeta(meta => ({
...meta,
@@ -55,9 +51,7 @@ export const createSelfHostBackup = async (
export const selfHostRestore = async (
{ host, backupFolder }: SelfHostData,
setMeta: (
transformer: (meta: BackgroundTaskMetadata) => BackgroundTaskMetadata,
) => void,
setMeta: TaskProgressUpdater,
) => {
setMeta(meta => ({
...meta,
+16 -16
View File
@@ -16,7 +16,7 @@ import {
import { BackupCategory, BackupNovel } from '@database/types';
import { BackupEntryName } from './types';
import { ROOT_STORAGE } from '@utils/Storages';
import ServiceManager from '@services/ServiceManager';
import { BACKGROUND_TASKS_STORE_KEY } from '@services/backgroundTasks';
import NativeFile from '@specs/NativeFile';
import { showToast } from '@utils/showToast';
import { getString } from '@strings/translations';
@@ -28,7 +28,7 @@ export const CACHE_DIR_PATH =
const backupMMKVData = () => {
const excludeKeys = [
ServiceManager.manager.STORE_KEY,
BACKGROUND_TASKS_STORE_KEY,
OLD_TRACKED_NOVEL_PREFIX,
SELF_HOST_BACKUP,
LAST_UPDATE_TIME,
@@ -58,15 +58,15 @@ const restoreMMKVData = (data: any) => {
export const prepareBackupData = async (cacheDirPath: string) => {
const novelDirPath = cacheDirPath + '/' + BackupEntryName.NOVEL_AND_CHAPTERS;
if (NativeFile.exists(novelDirPath)) {
NativeFile.unlink(novelDirPath);
if (await NativeFile.exists(novelDirPath)) {
await NativeFile.unlink(novelDirPath);
}
NativeFile.mkdir(novelDirPath); // this also creates cacheDirPath
await NativeFile.mkdir(novelDirPath); // this also creates cacheDirPath
// version
try {
NativeFile.writeFile(
await NativeFile.writeFile(
cacheDirPath + '/' + BackupEntryName.VERSION,
JSON.stringify({ version: version }),
);
@@ -84,7 +84,7 @@ export const prepareBackupData = async (cacheDirPath: string) => {
for (const novel of novels) {
try {
const chapters = await getNovelChapters(novel.id);
NativeFile.writeFile(
await NativeFile.writeFile(
novelDirPath + '/' + novel.id + '.json',
JSON.stringify({
chapters: chapters,
@@ -107,7 +107,7 @@ export const prepareBackupData = async (cacheDirPath: string) => {
try {
const categories = await getCategoriesFromDb();
const novelCategories = await getAllNovelCategories();
NativeFile.writeFile(
await NativeFile.writeFile(
cacheDirPath + '/' + BackupEntryName.CATEGORY,
JSON.stringify(
categories.map(category => {
@@ -130,7 +130,7 @@ export const prepareBackupData = async (cacheDirPath: string) => {
// settings
try {
NativeFile.writeFile(
await NativeFile.writeFile(
cacheDirPath + '/' + BackupEntryName.SETTING,
JSON.stringify(backupMMKVData()),
);
@@ -154,15 +154,15 @@ export const restoreData = async (cacheDirPath: string) => {
let novelCount = 0;
let failedCount = 0;
if (!NativeFile.exists(novelDirPath)) {
if (!(await NativeFile.exists(novelDirPath))) {
showToast(getString('backupScreen.novelDirectoryNotFound'));
} else {
try {
const items = NativeFile.readDir(novelDirPath);
const items = await NativeFile.readDir(novelDirPath);
for (const item of items) {
if (!item.isDirectory) {
try {
const fileContent = NativeFile.readFile(item.path);
const fileContent = await NativeFile.readFile(item.path);
const backupNovel = JSON.parse(fileContent) as BackupNovel;
if (!backupNovel.cover?.startsWith('http')) {
@@ -209,11 +209,11 @@ export const restoreData = async (cacheDirPath: string) => {
let categoryCount = 0;
let failedCategoryCount = 0;
if (!NativeFile.exists(categoryFilePath)) {
if (!(await NativeFile.exists(categoryFilePath))) {
showToast(getString('backupScreen.categoryFileNotFound'));
} else {
try {
const fileContent = NativeFile.readFile(categoryFilePath);
const fileContent = await NativeFile.readFile(categoryFilePath);
const categories: BackupCategory[] = JSON.parse(fileContent);
for (const category of categories) {
@@ -257,11 +257,11 @@ export const restoreData = async (cacheDirPath: string) => {
showToast(getString('backupScreen.restoringSettings'));
const settingsFilePath = cacheDirPath + '/' + BackupEntryName.SETTING;
if (!NativeFile.exists(settingsFilePath)) {
if (!(await NativeFile.exists(settingsFilePath))) {
showToast(getString('backupScreen.settingsFileNotFound'));
} else {
try {
const fileContent = NativeFile.readFile(settingsFilePath);
const fileContent = await NativeFile.readFile(settingsFilePath);
const settingsData = JSON.parse(fileContent);
restoreMMKVData(settingsData);
showToast(getString('backupScreen.settingsRestored'));
@@ -0,0 +1,28 @@
import { parseDownloadCheckpoint } from '../downloadCheckpoint';
describe('download checkpoint', () => {
it('restores the next chapter and previous failures', () => {
expect(
parseDownloadCheckpoint(
JSON.stringify({ nextIndex: 50, failures: ['Chapter 12 failed'] }),
200,
),
).toEqual({ nextIndex: 50, failures: ['Chapter 12 failed'] });
});
it('clamps a checkpoint to the current batch size', () => {
expect(
parseDownloadCheckpoint(
JSON.stringify({ nextIndex: 250, failures: [] }),
200,
).nextIndex,
).toBe(200);
});
it.each([undefined, 'invalid', JSON.stringify({ nextIndex: '50' })])(
'starts from the beginning for an invalid checkpoint',
checkpoint => {
expect(parseDownloadCheckpoint(checkpoint, 200).nextIndex).toBe(0);
},
);
});
+60 -15
View File
@@ -10,9 +10,13 @@ import { getChapterDownloadCooldownMs } from '@hooks/persisted/useSettings';
import { getNovelById } from '@database/queries/NovelQueries';
import { dbManager } from '@database/db';
import { chapterSchema } from '@database/schema';
import { BackgroundTaskMetadata } from '@services/ServiceManager';
import type {
BackgroundTaskExecutionContext,
TaskProgressUpdater,
} from '@services/backgroundTasks/contracts';
import NativeFile from '@specs/NativeFile';
import { eq } from 'drizzle-orm';
import { parseDownloadCheckpoint } from './downloadCheckpoint';
const createChapterFolder = async (
path: string,
@@ -24,9 +28,9 @@ const createChapterFolder = async (
): Promise<string> => {
const { pluginId, novelId, chapterId } = data;
const chapterFolder = `${path}/${pluginId}/${novelId}/${chapterId}`;
NativeFile.mkdir(chapterFolder);
await NativeFile.mkdir(chapterFolder);
const nomediaPath = chapterFolder + '/.nomedia';
NativeFile.writeFile(nomediaPath, ',');
await NativeFile.writeFile(nomediaPath, ',');
return chapterFolder;
};
@@ -57,20 +61,10 @@ const downloadFiles = async (
}
}
}
NativeFile.writeFile(folder + '/index.html', loadedCheerio.html());
await NativeFile.writeFile(folder + '/index.html', loadedCheerio.html());
};
export const downloadChapter = async (
{ chapterId }: { chapterId: number },
setMeta: (
transformer: (meta: BackgroundTaskMetadata) => BackgroundTaskMetadata,
) => void,
) => {
setMeta(meta => ({
...meta,
isRunning: true,
}));
const downloadChapter = async (chapterId: number) => {
const chapter = await getChapter(chapterId);
if (!chapter) {
throw new Error('Chapter not found with id: ' + chapterId);
@@ -101,10 +95,61 @@ export const downloadChapter = async (
} else {
throw new Error(getString('downloadScreen.chapterEmptyOrScrapeError'));
}
};
export const downloadChapters = async (
{
chapters,
}: {
novelName: string;
chapters: { chapterId: number; chapterName: string }[];
},
setMeta: TaskProgressUpdater,
context: BackgroundTaskExecutionContext,
) => {
if (!chapters.length) return;
const checkpoint = parseDownloadCheckpoint(
context.checkpoint,
chapters.length,
);
const failures = [...checkpoint.failures];
for (let index = checkpoint.nextIndex; index < chapters.length; index++) {
const chapter = chapters[index];
setMeta(meta => ({
...meta,
isRunning: true,
progress: index / chapters.length,
progressText: `${index + 1}/${chapters.length} · ${chapter.chapterName}`,
}));
try {
await downloadChapter(chapter.chapterId);
} catch (error) {
failures.push(
`${chapter.chapterName}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
await context.updateCheckpoint(
JSON.stringify({ nextIndex: index + 1, failures }),
);
}
setMeta(meta => ({
...meta,
progress: 1,
isRunning: false,
}));
if (failures.length) {
throw new Error(
`${failures.length} of ${
chapters.length
} chapters failed: ${failures.join('; ')}`,
);
}
};
@@ -0,0 +1,29 @@
export type DownloadCheckpoint = {
nextIndex: number;
failures: string[];
};
export const parseDownloadCheckpoint = (
checkpoint: string | undefined,
chapterCount: number,
): DownloadCheckpoint => {
if (!checkpoint) return { nextIndex: 0, failures: [] };
try {
const parsed = JSON.parse(checkpoint) as Partial<DownloadCheckpoint>;
return {
nextIndex:
typeof parsed.nextIndex === 'number' &&
Number.isInteger(parsed.nextIndex)
? Math.min(Math.max(parsed.nextIndex, 0), chapterCount)
: 0,
failures: Array.isArray(parsed.failures)
? parsed.failures.filter(
(failure): failure is string => typeof failure === 'string',
)
: [],
};
} catch {
return { nextIndex: 0, failures: [] };
}
};
+86 -25
View File
@@ -8,7 +8,11 @@ import { getString } from '@strings/translations';
import { NOVEL_STORAGE } from '@utils/Storages';
import { dbManager } from '@database/db';
import { novelSchema, chapterSchema } from '@database/schema';
import { BackgroundTaskMetadata } from '@services/ServiceManager';
import type {
BackgroundTaskMetadata,
EpubImportFile,
TaskProgressUpdater,
} from '@services/backgroundTasks/contracts';
import NativeFile from '@specs/NativeFile';
import NativeZipArchive from '@specs/NativeZipArchive';
import NativeEpub from '@specs/NativeEpub';
@@ -39,13 +43,13 @@ const insertLocalNovel = async (
if (insertId !== undefined && insertId >= 0) {
await updateNovelCategoryById(insertId, [2]);
const novelDir = NOVEL_STORAGE + '/local/' + insertId;
NativeFile.mkdir(novelDir);
await NativeFile.mkdir(novelDir);
const newCoverPath = `file://${novelDir}/${cover?.split(/[/\\]/).pop()}`;
if (cover) {
const decodedPath = decodePath(cover);
if (NativeFile.exists(decodedPath)) {
NativeFile.moveFile(decodedPath, newCoverPath);
if (await NativeFile.exists(decodedPath)) {
await NativeFile.moveFile(decodedPath, newCoverPath);
}
}
await updateNovelInfo({
@@ -89,7 +93,7 @@ const insertLocalChapter = async (
if (insertId !== undefined && insertId >= 0) {
let chapterText: string = '';
chapterText = NativeFile.readFile(decodePath(path));
chapterText = await NativeFile.readFile(decodePath(path));
if (!chapterText) {
return [];
}
@@ -100,24 +104,19 @@ const insertLocalChapter = async (
return `="file://${novelDir}/${$2.split(/[/\\]/).pop()}"`;
},
);
NativeFile.mkdir(novelDir + '/' + insertId);
NativeFile.writeFile(`${novelDir}/${insertId}/index.html`, chapterText);
await NativeFile.mkdir(novelDir + '/' + insertId);
await NativeFile.writeFile(
`${novelDir}/${insertId}/index.html`,
chapterText,
);
return;
}
throw new Error(getString('advancedSettingsScreen.chapterInsertFailed'));
};
export const importEpub = async (
{
uri,
filename,
}: {
uri: string;
filename: string;
},
setMeta: (
transformer: (meta: BackgroundTaskMetadata) => BackgroundTaskMetadata,
) => void,
{ uri, filename }: EpubImportFile,
setMeta: TaskProgressUpdater,
) => {
setMeta(meta => ({
...meta,
@@ -128,7 +127,7 @@ export const importEpub = async (
const epubFilePath =
NativeFile.getConstants().ExternalCachesDirectoryPath + '/novel.epub';
try {
NativeFile.copyFile(uri, epubFilePath);
await NativeFile.copyFile(uri, epubFilePath);
} catch {
throw new Error(
`Failed to read EPUB file "${filename}". The file may have been moved or deleted. Please try importing again.`,
@@ -136,10 +135,10 @@ export const importEpub = async (
}
const epubDirPath =
NativeFile.getConstants().ExternalCachesDirectoryPath + '/epub';
if (NativeFile.exists(epubDirPath)) {
NativeFile.unlink(epubDirPath);
if (await NativeFile.exists(epubDirPath)) {
await NativeFile.unlink(epubDirPath);
}
NativeFile.mkdir(epubDirPath);
await NativeFile.mkdir(epubDirPath);
await NativeZipArchive.unzip(epubFilePath, epubDirPath);
const novel = NativeEpub.parseNovelAndChapters(epubDirPath);
if (!novel.name) {
@@ -184,8 +183,8 @@ export const importEpub = async (
for (const filePath of novel.imagePaths) {
const decodedPath = decodePath(filePath);
if (NativeFile.exists(decodedPath)) {
NativeFile.moveFile(
if (await NativeFile.exists(decodedPath)) {
await NativeFile.moveFile(
decodedPath,
novelDir + '/' + filePath.split(/[/\\]/).pop(),
);
@@ -194,8 +193,8 @@ export const importEpub = async (
for (const filePath of novel.cssPaths) {
const decodedPath = decodePath(filePath);
if (NativeFile.exists(decodedPath)) {
NativeFile.moveFile(
if (await NativeFile.exists(decodedPath)) {
await NativeFile.moveFile(
decodedPath,
novelDir + '/' + filePath.split(/[/\\]/).pop(),
);
@@ -208,3 +207,65 @@ export const importEpub = async (
isRunning: false,
}));
};
export const importEpubBatch = async (
{ files }: { files: EpubImportFile[] },
setMeta: TaskProgressUpdater,
) => {
if (!files.length) return;
const failures: string[] = [];
for (let index = 0; index < files.length; index++) {
const file = files[index];
let fileMeta: BackgroundTaskMetadata = {
name: file.filename,
isRunning: true,
progress: 0,
progressText: undefined,
};
let progressError: unknown;
const updateFileProgress: TaskProgressUpdater = transformer => {
fileMeta = transformer(fileMeta);
try {
setMeta(meta => ({
...meta,
isRunning: true,
progress: (index + (fileMeta.progress ?? 0)) / files.length,
progressText: `${index + 1}/${files.length} · ${file.filename}${
fileMeta.progressText ? ` · ${fileMeta.progressText}` : ''
}`,
}));
} catch (error) {
progressError = error;
throw error;
}
};
try {
await importEpub(file, updateFileProgress);
} catch (error) {
if (error === progressError) throw error;
failures.push(
`${file.filename}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
setMeta(meta => ({
...meta,
progress: 1,
isRunning: false,
}));
if (failures.length) {
throw new Error(
`${failures.length} of ${
files.length
} EPUB imports failed: ${failures.join('; ')}`,
);
}
};
+23 -22
View File
@@ -1,4 +1,4 @@
import { NovelInfo, ChapterInfo } from '@database/types';
import { ChapterInfo } from '@database/types';
import {
getNovelByPath,
insertNovelAndChapters,
@@ -12,10 +12,12 @@ import {
novelPersistence,
type NovelPersistenceInput,
} from '@hooks/persisted/useNovel/store-helper/contracts';
import { sleep } from '@utils/sleep';
import ServiceManager, {
BackgroundTaskMetadata,
} from '@services/ServiceManager';
import type {
BackgroundTaskEnqueuer,
ChapterDownload,
MigrateNovelData,
TaskProgressUpdater,
} from '@services/backgroundTasks/contracts';
import { dbManager } from '@database/db';
import {
chapterSchema,
@@ -24,12 +26,6 @@ import {
} from '@database/schema';
import { eq } from 'drizzle-orm';
export interface MigrateNovelData {
pluginId: string;
fromNovel: NovelInfo;
toNovelPath: string;
}
const sortChaptersByNumber = (novelName: string, chapters: ChapterInfo[]) => {
for (let i = 0; i < chapters.length; ++i) {
if (!chapters[i].chapterNumber) {
@@ -49,9 +45,8 @@ const sortChaptersByNumber = (novelName: string, chapters: ChapterInfo[]) => {
export const migrateNovel = async (
{ pluginId, fromNovel, toNovelPath }: MigrateNovelData,
setMeta: (
transformer: (meta: BackgroundTaskMetadata) => BackgroundTaskMetadata,
) => void,
setMeta: TaskProgressUpdater,
enqueue: BackgroundTaskEnqueuer,
) => {
setMeta(meta => ({
...meta,
@@ -117,6 +112,7 @@ export const migrateNovel = async (
let fromPointer = 0,
toPointer = 0;
const chaptersToDownload: ChapterDownload[] = [];
while (fromPointer < fromChapters.length && toPointer < toChapters.length) {
const fromChapter = fromChapters[fromPointer];
const toChapter = toChapters[toPointer];
@@ -148,15 +144,10 @@ export const migrateNovel = async (
});
if (fromChapter.isDownloaded) {
ServiceManager.manager.addTask({
name: 'DOWNLOAD_CHAPTER',
data: {
chapterId: toChapter.id,
novelName: toNovel.name,
chapterName: toChapter.name,
},
chaptersToDownload.push({
chapterId: toChapter.id,
chapterName: toChapter.name,
});
await sleep(1000);
}
if (lastRead && fromChapter.id === lastRead.id) {
@@ -167,6 +158,16 @@ export const migrateNovel = async (
++toPointer;
}
if (chaptersToDownload.length) {
enqueue({
name: 'DOWNLOAD_CHAPTER',
data: {
novelName: toNovel.name,
chapters: chaptersToDownload,
},
});
}
setMeta(meta => ({
...meta,
isRunning: false,
+21 -11
View File
@@ -3,7 +3,7 @@ import { ChapterItem, SourceNovel } from '@plugins/types';
import { getPlugin, LOCAL_PLUGIN_ID } from '@plugins/pluginManager';
import { NOVEL_STORAGE } from '@utils/Storages';
import { downloadFile } from '@plugins/helpers/fetch';
import ServiceManager from '@services/ServiceManager';
import type { BackgroundTaskEnqueuer } from '@services/backgroundTasks/contracts';
import { dbManager } from '@database/db';
import { novelSchema, chapterSchema } from '@database/schema';
import { eq, and, inArray } from 'drizzle-orm';
@@ -22,8 +22,8 @@ const updateNovelMetadata = async (
let cover = novel.cover;
const novelDir = `${NOVEL_STORAGE}/${pluginId}/${novelId}`;
if (!NativeFile.exists(novelDir)) {
NativeFile.mkdir(novelDir);
if (!(await NativeFile.exists(novelDir))) {
await NativeFile.mkdir(novelDir);
}
if (cover) {
@@ -83,6 +83,7 @@ const updateNovelChapters = async (
chapters: ChapterItem[],
downloadNewChapters?: boolean,
page?: string,
enqueue?: BackgroundTaskEnqueuer,
) => {
if (!chapters.length) {
return;
@@ -114,7 +115,7 @@ const updateNovelChapters = async (
touchUpdatedTime: true,
});
if (downloadNewChapters && newPaths.length) {
if (downloadNewChapters && newPaths.length && enqueue) {
const insertedNewChapters = await dbManager
.select({
id: chapterSchema.id,
@@ -137,14 +138,17 @@ const updateNovelChapters = async (
]),
);
for (const insertedChapter of insertedNewChapters) {
ServiceManager.manager.addTask({
if (insertedNewChapters.length) {
enqueue({
name: 'DOWNLOAD_CHAPTER',
data: {
chapterId: insertedChapter.id,
novelName,
chapterName:
chapterNameByPath.get(insertedChapter.path) || insertedChapter.name,
chapters: insertedNewChapters.map(insertedChapter => ({
chapterId: insertedChapter.id,
chapterName:
chapterNameByPath.get(insertedChapter.path) ||
insertedChapter.name,
})),
},
});
}
@@ -154,6 +158,7 @@ const updateNovelChapters = async (
export interface UpdateNovelOptions {
downloadNewChapters?: boolean;
refreshNovelMetadata?: boolean;
enqueue?: BackgroundTaskEnqueuer;
}
const getStoredTotalPages = async (novelId: number): Promise<number> => {
@@ -178,7 +183,7 @@ const updateNovel = async (
if (pluginId === LOCAL_PLUGIN_ID) {
return;
}
const { downloadNewChapters, refreshNovelMetadata } = options;
const { downloadNewChapters, refreshNovelMetadata, enqueue } = options;
const oldTotalPages = await getStoredTotalPages(novelId);
@@ -194,6 +199,8 @@ const updateNovel = async (
novelId,
novel.chapters || [],
downloadNewChapters,
undefined,
enqueue,
);
// For paged novels: re-fetch the last known page and fetch any new pages
@@ -214,6 +221,7 @@ const updateNovel = async (
sourcePage.chapters || [],
downloadNewChapters,
String(oldTotalPages),
enqueue,
);
} catch {}
}
@@ -228,6 +236,7 @@ const updateNovel = async (
sourcePage.chapters || [],
downloadNewChapters,
String(page),
enqueue,
);
} catch {}
}
@@ -244,7 +253,7 @@ const updateNovelPage = async (
novelPath: string,
novelId: number,
page: string,
options: Pick<UpdateNovelOptions, 'downloadNewChapters'>,
options: Pick<UpdateNovelOptions, 'downloadNewChapters' | 'enqueue'>,
) => {
const { downloadNewChapters } = options;
const sourcePage = await fetchPage(pluginId, novelPath, page);
@@ -255,6 +264,7 @@ const updateNovelPage = async (
sourcePage.chapters || [],
downloadNewChapters,
page,
options.enqueue,
);
};
+9 -6
View File
@@ -4,14 +4,17 @@ import {
} from '../../database/queries/LibraryQueries';
import { showToast } from '../../utils/showToast';
import { UpdateNovelOptions, updateNovel } from './LibraryUpdateQueries';
import { DBNovelInfo } from '@database/types';
import { updateNovel, type UpdateNovelOptions } from './LibraryUpdateQueries';
import type { DBNovelInfo } from '@database/types';
import { sleep } from '@utils/sleep';
import { MMKVStorage, getMMKVObject } from '@utils/mmkv/mmkv';
import { LAST_UPDATE_TIME } from '@hooks/persisted/useUpdates';
import dayjs from 'dayjs';
import { APP_SETTINGS, AppSettings } from '@hooks/persisted/useSettings';
import { BackgroundTaskMetadata } from '@services/ServiceManager';
import type {
BackgroundTaskEnqueuer,
TaskProgressUpdater,
} from '@services/backgroundTasks/contracts';
const updateLibrary = async (
{
@@ -19,9 +22,8 @@ const updateLibrary = async (
}: {
categoryId?: number;
},
setMeta: (
transformer: (meta: BackgroundTaskMetadata) => BackgroundTaskMetadata,
) => void,
setMeta: TaskProgressUpdater,
enqueue: BackgroundTaskEnqueuer,
) => {
setMeta(meta => ({
...meta,
@@ -34,6 +36,7 @@ const updateLibrary = async (
const options: UpdateNovelOptions = {
downloadNewChapters: downloadNewChapters || false,
refreshNovelMetadata: refreshNovelMetadata || false,
enqueue,
};
let libraryNovels: DBNovelInfo[] = [];