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:
committed by
GitHub
parent
3e1e211098
commit
e4cf64a112
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+24
@@ -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)
|
||||
}
|
||||
+47
@@ -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",
|
||||
)
|
||||
}
|
||||
+18
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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)
|
||||
}
|
||||
+103
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
-56
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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
@@ -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('; ')}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
Reference in New Issue
Block a user