From e4cf64a112dbfd9f9dd853abec99afbdc09f1460 Mon Sep 17 00:00:00 2001 From: Rajarshee Chatterjee Date: Wed, 22 Jul 2026 09:21:18 +0530 Subject: [PATCH] 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 --- App.tsx | 31 +- android/app/build.gradle | 5 + android/app/src/main/AndroidManifest.xml | 10 +- .../LNReader/MainApplication.kt | 2 + .../NativeFile/NativeFile.kt | 253 ++++++++-- .../background/BackgroundTaskDao.kt | 39 ++ .../background/BackgroundTaskDatabase.kt | 24 + .../background/BackgroundTaskEntity.kt | 32 ++ .../background/BackgroundTaskScheduler.kt | 47 ++ .../background/LNReaderHeadlessTaskService.kt | 18 + .../background/LNReaderTaskWorker.kt | 90 ++++ .../background/NativeBackgroundTasks.kt | 188 +++++++ .../NativeBackgroundTasksPackage.kt | 26 + .../background/TaskActionReceiver.kt | 44 ++ .../background/TaskExecutionRegistry.kt | 29 ++ .../background/TaskNotificationFactory.kt | 103 ++++ index.js | 8 +- package.json | 2 - pnpm-lock.yaml | 56 --- specs/NativeBackgroundTasks.ts | 40 ++ specs/NativeFile.ts | 18 +- src/database/db.ts | 20 +- src/database/queries/ChapterQueries.ts | 12 +- src/database/queries/NovelQueries.ts | 8 +- src/hooks/common/useInitializeAppServices.ts | 57 +++ src/hooks/index.ts | 1 + src/hooks/persisted/useDownload.ts | 44 +- src/hooks/persisted/useImport.ts | 33 +- src/hooks/persisted/useNovel.ts | 4 +- .../useNovel/__tests__/persistence.test.ts | 9 +- src/hooks/persisted/usePlugins.ts | 3 +- src/navigators/Main.tsx | 4 +- src/plugins/pluginManager.ts | 56 ++- .../browse/migration/MigrationNovelList.tsx | 4 +- src/screens/library/LibraryScreen.tsx | 6 +- .../library/components/LibraryListView.tsx | 4 +- src/screens/library/hooks/useLibrary.ts | 7 +- src/screens/more/MoreScreen.tsx | 9 +- src/screens/more/TaskQueueScreen.tsx | 16 +- .../components/ExportNovelAsEpubButton.tsx | 6 +- .../novel/components/NovelScreenList.tsx | 8 +- src/screens/reader/hooks/useChapter.ts | 4 +- .../Components/GoogleDriveModal.tsx | 6 +- .../Components/SelfHostModal.tsx | 6 +- .../settings/SettingsBackupScreen/index.tsx | 42 +- .../SettingsReaderScreen/tabs/AdvancedTab.tsx | 6 +- src/screens/updates/UpdatesScreen.tsx | 6 +- src/services/ServiceManager.ts | 475 ------------------ .../backgroundTasks/BackgroundTaskQueue.ts | 235 +++++++++ .../__tests__/taskDefinitions.test.ts | 93 ++++ .../backgroundTasks/backgroundTasks.ts | 3 + src/services/backgroundTasks/contracts.ts | 78 +++ src/services/backgroundTasks/executeTask.ts | 43 ++ src/services/backgroundTasks/headlessTask.ts | 18 + src/services/backgroundTasks/index.ts | 17 + .../backgroundTasks/taskDefinitions.ts | 83 +++ src/services/backup/drive/index.ts | 10 +- src/services/backup/local/index.ts | 58 +-- src/services/backup/selfhost/index.ts | 18 +- src/services/backup/utils.ts | 32 +- .../__tests__/downloadCheckpoint.test.ts | 28 ++ src/services/download/downloadChapter.ts | 75 ++- src/services/download/downloadCheckpoint.ts | 29 ++ src/services/epub/import.ts | 111 +++- src/services/migrate/migrateNovel.ts | 45 +- src/services/updates/LibraryUpdateQueries.ts | 32 +- src/services/updates/index.ts | 15 +- 67 files changed, 2047 insertions(+), 897 deletions(-) create mode 100644 android/app/src/main/java/com/rajarsheechatterjee/background/BackgroundTaskDao.kt create mode 100644 android/app/src/main/java/com/rajarsheechatterjee/background/BackgroundTaskDatabase.kt create mode 100644 android/app/src/main/java/com/rajarsheechatterjee/background/BackgroundTaskEntity.kt create mode 100644 android/app/src/main/java/com/rajarsheechatterjee/background/BackgroundTaskScheduler.kt create mode 100644 android/app/src/main/java/com/rajarsheechatterjee/background/LNReaderHeadlessTaskService.kt create mode 100644 android/app/src/main/java/com/rajarsheechatterjee/background/LNReaderTaskWorker.kt create mode 100644 android/app/src/main/java/com/rajarsheechatterjee/background/NativeBackgroundTasks.kt create mode 100644 android/app/src/main/java/com/rajarsheechatterjee/background/NativeBackgroundTasksPackage.kt create mode 100644 android/app/src/main/java/com/rajarsheechatterjee/background/TaskActionReceiver.kt create mode 100644 android/app/src/main/java/com/rajarsheechatterjee/background/TaskExecutionRegistry.kt create mode 100644 android/app/src/main/java/com/rajarsheechatterjee/background/TaskNotificationFactory.kt create mode 100644 specs/NativeBackgroundTasks.ts create mode 100644 src/hooks/common/useInitializeAppServices.ts delete mode 100644 src/services/ServiceManager.ts create mode 100644 src/services/backgroundTasks/BackgroundTaskQueue.ts create mode 100644 src/services/backgroundTasks/__tests__/taskDefinitions.test.ts create mode 100644 src/services/backgroundTasks/backgroundTasks.ts create mode 100644 src/services/backgroundTasks/contracts.ts create mode 100644 src/services/backgroundTasks/executeTask.ts create mode 100644 src/services/backgroundTasks/headlessTask.ts create mode 100644 src/services/backgroundTasks/index.ts create mode 100644 src/services/backgroundTasks/taskDefinitions.ts create mode 100644 src/services/download/__tests__/downloadCheckpoint.test.ts create mode 100644 src/services/download/downloadCheckpoint.ts diff --git a/App.tsx b/App.tsx index f3fbb55f..231c2a7d 100644 --- a/App.tsx +++ b/App.tsx @@ -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 null} />; + const initializationError = databaseError || servicesError; + + if (initializationError) { + return ( + null} /> + ); } - if (!state.success) { + if (!databaseReady || !servicesReady) { return null; } diff --git a/android/app/build.gradle b/android/app/build.gradle index 6aa7b52f..e46daec4 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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") diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2d8e7b56..feeb2540 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,4 @@ - + @@ -10,6 +10,8 @@ + + @@ -20,9 +22,9 @@ - - - + + + diff --git a/android/app/src/main/java/com/rajarsheechatterjee/LNReader/MainApplication.kt b/android/app/src/main/java/com/rajarsheechatterjee/LNReader/MainApplication.kt index 856a3172..aa8e0842 100644 --- a/android/app/src/main/java/com/rajarsheechatterjee/LNReader/MainApplication.kt +++ b/android/app/src/main/java/com/rajarsheechatterjee/LNReader/MainApplication.kt @@ -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()) }, ) } diff --git a/android/app/src/main/java/com/rajarsheechatterjee/NativeFile/NativeFile.kt b/android/app/src/main/java/com/rajarsheechatterjee/NativeFile/NativeFile.kt index ed772918..a021fa3b 100644 --- a/android/app/src/main/java/com/rajarsheechatterjee/NativeFile/NativeFile.kt +++ b/android/app/src/main/java/com/rajarsheechatterjee/NativeFile/NativeFile.kt @@ -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 + } } diff --git a/android/app/src/main/java/com/rajarsheechatterjee/background/BackgroundTaskDao.kt b/android/app/src/main/java/com/rajarsheechatterjee/background/BackgroundTaskDao.kt new file mode 100644 index 00000000..a308d1da --- /dev/null +++ b/android/app/src/main/java/com/rajarsheechatterjee/background/BackgroundTaskDao.kt @@ -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 + + @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) +} diff --git a/android/app/src/main/java/com/rajarsheechatterjee/background/BackgroundTaskDatabase.kt b/android/app/src/main/java/com/rajarsheechatterjee/background/BackgroundTaskDatabase.kt new file mode 100644 index 00000000..8b6f8c60 --- /dev/null +++ b/android/app/src/main/java/com/rajarsheechatterjee/background/BackgroundTaskDatabase.kt @@ -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 } + } + } +} diff --git a/android/app/src/main/java/com/rajarsheechatterjee/background/BackgroundTaskEntity.kt b/android/app/src/main/java/com/rajarsheechatterjee/background/BackgroundTaskEntity.kt new file mode 100644 index 00000000..8063f1f9 --- /dev/null +++ b/android/app/src/main/java/com/rajarsheechatterjee/background/BackgroundTaskEntity.kt @@ -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) +} diff --git a/android/app/src/main/java/com/rajarsheechatterjee/background/BackgroundTaskScheduler.kt b/android/app/src/main/java/com/rajarsheechatterjee/background/BackgroundTaskScheduler.kt new file mode 100644 index 00000000..010129b3 --- /dev/null +++ b/android/app/src/main/java/com/rajarsheechatterjee/background/BackgroundTaskScheduler.kt @@ -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() + .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", + ) +} diff --git a/android/app/src/main/java/com/rajarsheechatterjee/background/LNReaderHeadlessTaskService.kt b/android/app/src/main/java/com/rajarsheechatterjee/background/LNReaderHeadlessTaskService.kt new file mode 100644 index 00000000..ad8751a3 --- /dev/null +++ b/android/app/src/main/java/com/rajarsheechatterjee/background/LNReaderHeadlessTaskService.kt @@ -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, + ) + } +} diff --git a/android/app/src/main/java/com/rajarsheechatterjee/background/LNReaderTaskWorker.kt b/android/app/src/main/java/com/rajarsheechatterjee/background/LNReaderTaskWorker.kt new file mode 100644 index 00000000..e5b9d224 --- /dev/null +++ b/android/app/src/main/java/com/rajarsheechatterjee/background/LNReaderTaskWorker.kt @@ -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) + } + } +} diff --git a/android/app/src/main/java/com/rajarsheechatterjee/background/NativeBackgroundTasks.kt b/android/app/src/main/java/com/rajarsheechatterjee/background/NativeBackgroundTasks.kt new file mode 100644 index 00000000..022dcac6 --- /dev/null +++ b/android/app/src/main/java/com/rajarsheechatterjee/background/NativeBackgroundTasks.kt @@ -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) + }) + } + } +} diff --git a/android/app/src/main/java/com/rajarsheechatterjee/background/NativeBackgroundTasksPackage.kt b/android/app/src/main/java/com/rajarsheechatterjee/background/NativeBackgroundTasksPackage.kt new file mode 100644 index 00000000..6ae4c7db --- /dev/null +++ b/android/app/src/main/java/com/rajarsheechatterjee/background/NativeBackgroundTasksPackage.kt @@ -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, + ), + ) + } +} diff --git a/android/app/src/main/java/com/rajarsheechatterjee/background/TaskActionReceiver.kt b/android/app/src/main/java/com/rajarsheechatterjee/background/TaskActionReceiver.kt new file mode 100644 index 00000000..fedd79a9 --- /dev/null +++ b/android/app/src/main/java/com/rajarsheechatterjee/background/TaskActionReceiver.kt @@ -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() + } + } + } +} diff --git a/android/app/src/main/java/com/rajarsheechatterjee/background/TaskExecutionRegistry.kt b/android/app/src/main/java/com/rajarsheechatterjee/background/TaskExecutionRegistry.kt new file mode 100644 index 00000000..49b80862 --- /dev/null +++ b/android/app/src/main/java/com/rajarsheechatterjee/background/TaskExecutionRegistry.kt @@ -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>() + + fun register(taskId: String): CompletableDeferred { + val execution = CompletableDeferred() + 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) +} diff --git a/android/app/src/main/java/com/rajarsheechatterjee/background/TaskNotificationFactory.kt b/android/app/src/main/java/com/rajarsheechatterjee/background/TaskNotificationFactory.kt new file mode 100644 index 00000000..6adc4b3c --- /dev/null +++ b/android/app/src/main/java/com/rajarsheechatterjee/background/TaskNotificationFactory.kt @@ -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, + ) +} diff --git a/index.js b/index.js index 6d720428..2f2bf807 100644 --- a/index.js +++ b/index.js @@ -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); diff --git a/package.json b/package.json index f5ca6e0f..3dde0ddd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55203247..91d4a17a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) diff --git a/specs/NativeBackgroundTasks.ts b/specs/NativeBackgroundTasks.ts new file mode 100644 index 00000000..605c69d6 --- /dev/null +++ b/specs/NativeBackgroundTasks.ts @@ -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; + getTasks(): Promise; + pause(taskId: string): Promise; + resume(taskId: string): Promise; + cancel(taskId: string): Promise; + updateProgress( + taskId: string, + progress: number, + progressText: string, + ): Promise; + updateCheckpoint(taskId: string, checkpoint: string): Promise; + complete(taskId: string): Promise; + fail(taskId: string, error: string, shouldRetry: boolean): Promise; +} + +export default TurboModuleRegistry.getEnforcing('NativeBackgroundTasks'); diff --git a/specs/NativeFile.ts b/specs/NativeFile.ts index 66fc6fbf..ae4d5eda 100644 --- a/specs/NativeFile.ts +++ b/specs/NativeFile.ts @@ -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; + readFile: (path: string) => Promise; + copyFile: (sourcePath: string, destPath: string) => Promise; + moveFile: (sourcePath: string, destPath: string) => Promise; + exists: (filePath: string) => Promise; /** * @description create parents, and do nothing if exists; */ - mkdir: (filePath: string) => void; + mkdir: (filePath: string) => Promise; /** * @description remove recursively */ - unlink: (filePath: string) => void; - readDir: (dirPath: string) => ReadDirResult[]; + unlink: (filePath: string) => Promise; + readDir: (dirPath: string) => Promise; + createDocument: (filename: string, mimeType: string) => Promise; + pickDocument: (mimeType: string) => Promise; downloadFile: ( url: string, destPath: string, diff --git a/src/database/db.ts b/src/database/db.ts index 3049ee00..35b64b2d 100644 --- a/src/database/db.ts +++ b/src/database/db.ts @@ -175,6 +175,19 @@ export const runDatabaseBootstrap = (executor: SqlExecutor) => { populateDatabase(executor); }; +let initialization: Promise | 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, diff --git a/src/database/queries/ChapterQueries.ts b/src/database/queries/ChapterQueries.ts index ed103960..b350a1dc 100644 --- a/src/database/queries/ChapterQueries.ts +++ b/src/database/queries/ChapterQueries.ts @@ -175,14 +175,14 @@ export const markAllChaptersUnread = async (novelId: number): Promise => { }); }; -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 => { - 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 => { diff --git a/src/database/queries/NovelQueries.ts b/src/database/queries/NovelQueries.ts index 74ea351e..241b8a44 100644 --- a/src/database/queries/NovelQueries.ts +++ b/src/database/queries/NovelQueries.ts @@ -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 diff --git a/src/hooks/common/useInitializeAppServices.ts b/src/hooks/common/useInitializeAppServices.ts new file mode 100644 index 00000000..3c6c13b3 --- /dev/null +++ b/src/hooks/common/useInitializeAppServices.ts @@ -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 | undefined; + +const initializeAppServices = (): Promise => { + 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({ 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; +}; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 73fd4a46..7471a373 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -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'; diff --git a/src/hooks/persisted/useDownload.ts b/src/hooks/persisted/useDownload.ts index 10ec6f92..9e05456f 100644 --- a/src/hooks/persisted/useDownload.ts +++ b/src/hooks/persisted/useDownload.ts @@ -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( - 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, }; } diff --git a/src/hooks/persisted/useImport.ts b/src/hooks/persisted/useImport.ts index 7f104d8e..7a035e30 100644 --- a/src/hooks/persisted/useImport.ts +++ b/src/hooks/persisted/useImport.ts @@ -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( - ServiceManager.manager.STORE_KEY, + const [queue] = useMMKVObject( + 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, diff --git a/src/hooks/persisted/useNovel.ts b/src/hooks/persisted/useNovel.ts index dd4c3cd0..d2305836 100644 --- a/src/hooks/persisted/useNovel.ts +++ b/src/hooks/persisted/useNovel.ts @@ -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); } } diff --git a/src/hooks/persisted/useNovel/__tests__/persistence.test.ts b/src/hooks/persisted/useNovel/__tests__/persistence.test.ts index 1d27cb60..887b1f61 100644 --- a/src/hooks/persisted/useNovel/__tests__/persistence.test.ts +++ b/src/hooks/persisted/useNovel/__tests__/persistence.test.ts @@ -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(), }, })); diff --git a/src/hooks/persisted/usePlugins.ts b/src/hooks/persisted/usePlugins.ts index e6a7196b..53f151bd 100644 --- a/src/hooks/persisted/usePlugins.ts +++ b/src/hooks/persisted/usePlugins.ts @@ -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'; diff --git a/src/navigators/Main.tsx b/src/navigators/Main.tsx index cb51faa8..78ac92e9 100644 --- a/src/navigators/Main.tsx +++ b/src/navigators/Main.tsx @@ -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; diff --git a/src/plugins/pluginManager.ts b/src/plugins/pluginManager.ts index 712f9793..59eea517 100644 --- a/src/plugins/pluginManager.ts +++ b/src/plugins/pluginManager.ts @@ -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 = {}; +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(INSTALLED_PLUGINS_KEY) || []; + await Promise.all(installedPlugins.map(plugin => loadPlugin(plugin.id))); +}; + const LOCAL_PLUGIN_ID = 'local'; export { getPlugin, + loadPlugin, + initializeInstalledPlugins, installPlugin, uninstallPlugin, updatePlugin, diff --git a/src/screens/browse/migration/MigrationNovelList.tsx b/src/screens/browse/migration/MigrationNovelList.tsx index 8c133910..9b349dfd 100644 --- a/src/screens/browse/migration/MigrationNovelList.tsx +++ b/src/screens/browse/migration/MigrationNovelList.tsx @@ -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 = ({