diff --git a/app/build.gradle b/app/build.gradle
index 0c1561411..64be18d7a 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdk = 21
targetSdk = 35
- versionCode = 683
- versionName = '7.7-a4'
+ versionCode = 685
+ versionName = '7.7-a6'
generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp {
@@ -82,7 +82,7 @@ afterEvaluate {
}
}
dependencies {
- implementation('com.github.KotatsuApp:kotatsu-parsers:1.4') {
+ implementation('com.github.KotatsuApp:kotatsu-parsers:79e1d59482') {
exclude group: 'org.json', module: 'json'
}
@@ -134,9 +134,10 @@ dependencies {
implementation 'androidx.hilt:hilt-work:1.2.0'
kapt 'androidx.hilt:hilt-compiler:1.2.0'
- implementation 'io.coil-kt.coil3:coil-core:3.0.0-rc01'
- implementation 'io.coil-kt.coil3:coil-network-okhttp:3.0.0-rc01'
- implementation 'io.coil-kt.coil3:coil-gif:3.0.0-rc01'
+ implementation 'io.coil-kt.coil3:coil-core:3.0.0-rc02'
+ implementation 'io.coil-kt.coil3:coil-network-okhttp:3.0.0-rc02'
+ implementation 'io.coil-kt.coil3:coil-gif:3.0.0-rc02'
+ implementation 'io.coil-kt.coil3:coil-svg:3.0.0-rc02'
implementation 'org.aomedia.avif.android:avif:1.1.1.14d8e3c4'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:d1d10a6975'
implementation 'com.github.solkin:disk-lru-cache:1.4'
diff --git a/app/src/debug/kotlin/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt b/app/src/debug/kotlin/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt
index e6a9ce01c..fc3780585 100644
--- a/app/src/debug/kotlin/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt
+++ b/app/src/debug/kotlin/org/koitharu/kotatsu/core/network/CurlLoggingInterceptor.kt
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.network
import android.util.Log
import okhttp3.Interceptor
+import okhttp3.Request
import okhttp3.Response
import okio.Buffer
import org.koitharu.kotatsu.core.network.CommonHeaders.ACCEPT_ENCODING
@@ -12,8 +13,11 @@ class CurlLoggingInterceptor(
private val escapeRegex = Regex("([\\[\\]\"])")
- override fun intercept(chain: Interceptor.Chain): Response {
- val request = chain.request()
+ override fun intercept(chain: Interceptor.Chain): Response = chain.proceed(chain.request()).also {
+ logRequest(it.networkResponse?.request ?: it.request)
+ }
+
+ private fun logRequest(request: Request) {
var isCompressed = false
val curlCmd = StringBuilder()
@@ -46,8 +50,6 @@ class CurlLoggingInterceptor(
log("---cURL (" + request.url + ")")
log(curlCmd.toString())
-
- return chain.proceed(request)
}
private fun String.escape() = replace(escapeRegex) { match ->
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index cd7988db3..97db999be 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -266,19 +266,26 @@
tools:node="merge" />
+ android:foregroundServiceType="dataSync"
+ android:label="@string/local_manga_processing" />
+ android:name="org.koitharu.kotatsu.settings.backup.PeriodicalBackupService"
+ android:foregroundServiceType="dataSync"
+ android:label="@string/periodic_backups" />
-
+ android:foregroundServiceType="dataSync"
+ android:label="@string/fixing_manga" />
+
+ android:exported="false"
+ android:label="@string/prefetch_content" />
{
+
+ override fun compareTo(other: BackupFile): Int = compareValues(dateTime, other.dateTime)
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt
index dbfe477c3..a791a4e98 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupZipOutput.kt
@@ -5,10 +5,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.Closeable
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.zip.ZipOutput
import java.io.File
-import java.time.LocalDate
+import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
+import java.time.format.DateTimeParseException
import java.util.Locale
import java.util.zip.Deflater
@@ -27,20 +29,32 @@ class BackupZipOutput(val file: File) : Closeable {
override fun close() {
output.close()
}
-}
-const val DIR_BACKUPS = "backups"
+ companion object {
-suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
- val dir = context.run {
- getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
+ const val DIR_BACKUPS = "backups"
+ private val dateTimeFormat = DateTimeFormatter.ofPattern("yyyyMMdd-HHmm")
+
+ fun generateFileName(context: Context) = buildString {
+ append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
+ append('_')
+ append(LocalDateTime.now().format(dateTimeFormat))
+ append(".bk.zip")
+ }
+
+ fun parseBackupDateTime(fileName: String): LocalDateTime? = try {
+ LocalDateTime.parse(fileName.substringAfterLast('_').substringBefore('.'), dateTimeFormat)
+ } catch (e: DateTimeParseException) {
+ e.printStackTraceDebug()
+ null
+ }
+
+ suspend fun createTemp(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
+ val dir = context.run {
+ getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
+ }
+ dir.mkdirs()
+ BackupZipOutput(File(dir, generateFileName(context)))
+ }
}
- dir.mkdirs()
- val filename = buildString {
- append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
- append('_')
- append(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")))
- append(".bk.zip")
- }
- BackupZipOutput(File(dir, filename))
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/ExternalBackupStorage.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/ExternalBackupStorage.kt
new file mode 100644
index 000000000..9cf5b4cae
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/ExternalBackupStorage.kt
@@ -0,0 +1,75 @@
+package org.koitharu.kotatsu.core.backup
+
+import android.content.Context
+import android.net.Uri
+import androidx.documentfile.provider.DocumentFile
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runInterruptible
+import okio.IOException
+import okio.buffer
+import okio.sink
+import okio.source
+import org.jetbrains.annotations.Blocking
+import org.koitharu.kotatsu.core.prefs.AppSettings
+import java.io.File
+import javax.inject.Inject
+
+class ExternalBackupStorage @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val settings: AppSettings,
+) {
+
+ suspend fun list(): List = runInterruptible(Dispatchers.IO) {
+ getRoot().listFiles().mapNotNull {
+ if (it.isFile && it.canRead()) {
+ BackupFile(
+ uri = it.uri,
+ dateTime = it.name?.let { fileName ->
+ BackupZipOutput.parseBackupDateTime(fileName)
+ } ?: return@mapNotNull null,
+ )
+ } else {
+ null
+ }
+ }.sortedDescending()
+ }
+
+ suspend fun put(file: File): Uri = runInterruptible(Dispatchers.IO) {
+ val out = checkNotNull(getRoot().createFile("application/zip", file.nameWithoutExtension)) {
+ "Cannot create target backup file"
+ }
+ checkNotNull(context.contentResolver.openOutputStream(out.uri, "wt")).sink().use { sink ->
+ file.source().buffer().use { src ->
+ src.readAll(sink)
+ }
+ }
+ out.uri
+ }
+
+ suspend fun delete(victim: BackupFile) = runInterruptible(Dispatchers.IO) {
+ val df = checkNotNull(DocumentFile.fromSingleUri(context, victim.uri)) {
+ "${victim.uri} cannot be resolved to the DocumentFile"
+ }
+ if (!df.delete()) {
+ throw IOException("Cannot delete ${df.uri}")
+ }
+ }
+
+ suspend fun getLastBackupDate() = list().maxByOrNull { it.dateTime }?.dateTime
+
+ suspend fun trim(maxCount: Int) {
+ list().drop(maxCount).forEach {
+ delete(it)
+ }
+ }
+
+ @Blocking
+ private fun getRoot(): DocumentFile {
+ val uri = checkNotNull(settings.periodicalBackupDirectory) {
+ "Backup directory is not specified"
+ }
+ val root = DocumentFile.fromTreeUri(context, uri)
+ return checkNotNull(root) { "Cannot obtain DocumentFile from $uri" }
+ }
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt
index 66d172ba9..2e50100ad 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt
@@ -473,7 +473,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val periodicalBackupFrequency: Long
get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toLongOrNull() ?: 7L
- var periodicalBackupOutput: Uri?
+ val periodicalBackupMaxCount: Int
+ get() = prefs.getInt(KEY_BACKUP_PERIODICAL_COUNT, 10)
+
+ var periodicalBackupDirectory: Uri?
get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull()
set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) }
@@ -621,6 +624,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_RESTORE = "restore"
const val KEY_BACKUP_PERIODICAL_ENABLED = "backup_periodic"
const val KEY_BACKUP_PERIODICAL_FREQUENCY = "backup_periodic_freq"
+ const val KEY_BACKUP_PERIODICAL_COUNT = "backup_periodic_count"
const val KEY_BACKUP_PERIODICAL_OUTPUT = "backup_periodic_output"
const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last"
const val KEY_HISTORY_GROUPING = "history_grouping"
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt
index 5441134cf..1c555b82e 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt
@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.ui
+import android.app.Notification
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
@@ -9,11 +10,10 @@ import android.os.PatternMatcher
import androidx.annotation.AnyThread
import androidx.annotation.WorkerThread
import androidx.core.app.PendingIntentCompat
+import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
@@ -21,60 +21,104 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
+import kotlin.coroutines.CoroutineContext
abstract class CoroutineIntentService : BaseService() {
private val mutex = Mutex()
- protected open val dispatcher: CoroutineDispatcher = Dispatchers.Default
final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
- val job = launchCoroutine(intent, startId)
- val receiver = CancelReceiver(job)
- ContextCompat.registerReceiver(
- this,
- receiver,
- createIntentFilter(this, startId),
- ContextCompat.RECEIVER_NOT_EXPORTED,
- )
- job.invokeOnCompletion { unregisterReceiver(receiver) }
+ launchCoroutine(intent, startId)
return START_REDELIVER_INTENT
}
- private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch(errorHandler(startId)) {
+ private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch {
+ val intentJobContext = IntentJobContextImpl(startId, coroutineContext)
mutex.withLock {
try {
if (intent != null) {
- withContext(dispatcher) {
- processIntent(startId, intent)
+ withContext(Dispatchers.Default) {
+ intentJobContext.processIntent(intent)
}
}
} catch (e: Throwable) {
e.printStackTraceDebug()
- onError(startId, e)
+ intentJobContext.onError(e)
} finally {
- stopSelf(startId)
+ intentJobContext.stop()
}
}
}
@WorkerThread
- protected abstract suspend fun processIntent(startId: Int, intent: Intent)
+ protected abstract suspend fun IntentJobContext.processIntent(intent: Intent)
@AnyThread
- protected abstract fun onError(startId: Int, error: Throwable)
+ protected abstract fun IntentJobContext.onError(error: Throwable)
- protected fun getCancelIntent(startId: Int) = PendingIntentCompat.getBroadcast(
- this,
- 0,
- createCancelIntent(this, startId),
- PendingIntent.FLAG_UPDATE_CURRENT,
- false,
- )
+ interface IntentJobContext {
- private fun errorHandler(startId: Int) = CoroutineExceptionHandler { _, throwable ->
- throwable.printStackTraceDebug()
- onError(startId, throwable)
+ val startId: Int
+
+ fun getCancelIntent(): PendingIntent?
+
+ fun setForeground(id: Int, notification: Notification, serviceType: Int)
+ }
+
+ protected inner class IntentJobContextImpl(
+ override val startId: Int,
+ private val coroutineContext: CoroutineContext,
+ ) : IntentJobContext {
+
+ private var cancelReceiver: CancelReceiver? = null
+ private var isStopped = false
+ private var isForeground = false
+
+ override fun getCancelIntent(): PendingIntent? {
+ ensureHasCancelReceiver()
+ return PendingIntentCompat.getBroadcast(
+ applicationContext,
+ 0,
+ createCancelIntent(this@CoroutineIntentService, startId),
+ PendingIntent.FLAG_UPDATE_CURRENT,
+ false,
+ )
+ }
+
+ override fun setForeground(id: Int, notification: Notification, serviceType: Int) {
+ ServiceCompat.startForeground(this@CoroutineIntentService, id, notification, serviceType)
+ isForeground = true
+ }
+
+ fun stop() {
+ synchronized(this) {
+ cancelReceiver?.let { unregisterReceiver(it) }
+ isStopped = true
+ }
+ if (isForeground) {
+ ServiceCompat.stopForeground(this@CoroutineIntentService, ServiceCompat.STOP_FOREGROUND_REMOVE)
+ }
+ stopSelf(startId)
+ }
+
+ private fun ensureHasCancelReceiver() {
+ if (cancelReceiver == null && !isStopped) {
+ synchronized(this) {
+ if (cancelReceiver == null && !isStopped) {
+ val job = coroutineContext[Job] ?: return
+ cancelReceiver = CancelReceiver(job).also { receiver ->
+ ContextCompat.registerReceiver(
+ applicationContext,
+ receiver,
+ createIntentFilter(this@CoroutineIntentService, startId),
+ ContextCompat.RECEIVER_NOT_EXPORTED,
+ )
+ }
+ }
+ }
+ }
+ }
}
private class CancelReceiver(
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt
index 1caca8498..bf0d17f9e 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/service/MangaPrefetchService.kt
@@ -34,7 +34,7 @@ class MangaPrefetchService : CoroutineIntentService() {
@Inject
lateinit var historyRepository: HistoryRepository
- override suspend fun processIntent(startId: Int, intent: Intent) {
+ override suspend fun IntentJobContext.processIntent(intent: Intent) {
when (intent.action) {
ACTION_PREFETCH_DETAILS -> prefetchDetails(
manga = intent.getParcelableExtraCompat(EXTRA_MANGA)?.manga
@@ -50,7 +50,7 @@ class MangaPrefetchService : CoroutineIntentService() {
}
}
- override fun onError(startId: Int, error: Throwable) = Unit
+ override fun IntentJobContext.onError(error: Throwable) = Unit
private suspend fun prefetchDetails(manga: Manga) {
val source = mangaRepositoryFactory.create(manga.source)
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/MangaIndex.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/MangaIndex.kt
index 0ee9a3940..ae7daef04 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/MangaIndex.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/MangaIndex.kt
@@ -196,6 +196,9 @@ class MangaIndex(source: String?) {
@Blocking
@WorkerThread
fun read(fileSystem: FileSystem, path: Path): MangaIndex? = runCatchingCancellable {
+ if (!fileSystem.exists(path)) {
+ return@runCatchingCancellable null
+ }
val text = fileSystem.source(path).use {
it.buffer().use { buffer ->
buffer.readUtf8()
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt
index bdf47e8ab..c1a6f010b 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt
@@ -11,7 +11,6 @@ import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
-import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import coil3.ImageLoader
import coil3.request.ImageRequest
@@ -48,23 +47,19 @@ class ImportService : CoroutineIntentService() {
notificationManager = NotificationManagerCompat.from(applicationContext)
}
- override suspend fun processIntent(startId: Int, intent: Intent) {
+ override suspend fun IntentJobContext.processIntent(intent: Intent) {
val uri = requireNotNull(intent.getStringExtra(DATA_URI)?.toUriOrNull()) { "No input uri" }
- startForeground()
- try {
- val result = runCatchingCancellable {
- importer.import(uri).manga
- }
- if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
- val notification = buildNotification(result)
- notificationManager.notify(TAG, startId, notification)
- }
- } finally {
- ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
+ startForeground(this)
+ val result = runCatchingCancellable {
+ importer.import(uri).manga
+ }
+ if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
+ val notification = buildNotification(result)
+ notificationManager.notify(TAG, startId, notification)
}
}
- override fun onError(startId: Int, error: Throwable) {
+ override fun IntentJobContext.onError(error: Throwable) {
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
val notification = runBlocking { buildNotification(Result.failure(error)) }
notificationManager.notify(TAG, startId, notification)
@@ -72,7 +67,7 @@ class ImportService : CoroutineIntentService() {
}
@SuppressLint("InlinedApi")
- private fun startForeground() {
+ private fun startForeground(jobContext: IntentJobContext) {
val title = applicationContext.getString(R.string.importing_manga)
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
.setName(title)
@@ -95,8 +90,7 @@ class ImportService : CoroutineIntentService() {
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.build()
- ServiceCompat.startForeground(
- this,
+ jobContext.setForeground(
FOREGROUND_NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt
index 6078bac27..6c4625f31 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt
@@ -1,12 +1,13 @@
package org.koitharu.kotatsu.local.ui
+import android.annotation.SuppressLint
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
+import android.content.pm.ServiceInfo
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
-import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -42,21 +43,17 @@ class LocalChaptersRemoveService : CoroutineIntentService() {
super.onDestroy()
}
- override suspend fun processIntent(startId: Int, intent: Intent) {
+ override suspend fun IntentJobContext.processIntent(intent: Intent) {
val manga = intent.getParcelableExtraCompat(EXTRA_MANGA)?.manga ?: return
val chaptersIds = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toSet() ?: return
- startForeground()
- try {
- val mangaWithChapters = localMangaRepository.getDetails(manga)
- localMangaRepository.deleteChapters(mangaWithChapters, chaptersIds)
- localStorageChanges.emit(LocalManga(localMangaRepository.getDetails(manga)))
- } finally {
- ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
- }
+ startForeground(this)
+ val mangaWithChapters = localMangaRepository.getDetails(manga)
+ localMangaRepository.deleteChapters(mangaWithChapters, chaptersIds)
+ localStorageChanges.emit(LocalManga(localMangaRepository.getDetails(manga)))
}
- override fun onError(startId: Int, error: Throwable) {
- val notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ override fun IntentJobContext.onError(error: Throwable) {
+ val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setContentTitle(getString(R.string.error_occurred))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setDefaults(0)
@@ -64,13 +61,14 @@ class LocalChaptersRemoveService : CoroutineIntentService() {
.setContentText(error.getDisplayMessage(resources))
.setSmallIcon(android.R.drawable.stat_notify_error)
.setAutoCancel(true)
- .setContentIntent(ErrorReporterReceiver.getPendingIntent(this, error))
+ .setContentIntent(ErrorReporterReceiver.getPendingIntent(applicationContext, error))
.build()
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIFICATION_ID + startId, notification)
}
- private fun startForeground() {
+ @SuppressLint("InlinedApi")
+ private fun startForeground(jobContext: IntentJobContext) {
val title = getString(R.string.local_manga_processing)
val manager = NotificationManagerCompat.from(this)
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
@@ -92,7 +90,7 @@ class LocalChaptersRemoveService : CoroutineIntentService() {
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
.setOngoing(false)
.build()
- startForeground(NOTIFICATION_ID, notification)
+ jobContext.setForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
}
companion object {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalIndexUpdateService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalIndexUpdateService.kt
index 72e287b17..aae893944 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalIndexUpdateService.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalIndexUpdateService.kt
@@ -12,9 +12,9 @@ class LocalIndexUpdateService : CoroutineIntentService() {
@Inject
lateinit var localMangaIndex: LocalMangaIndex
- override suspend fun processIntent(startId: Int, intent: Intent) {
+ override suspend fun IntentJobContext.processIntent(intent: Intent) {
localMangaIndex.update()
}
- override fun onError(startId: Int, error: Throwable) = Unit
+ override fun IntentJobContext.onError(error: Throwable) = Unit
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt
index 3e567ede1..87181e871 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt
@@ -72,6 +72,7 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.about.AppUpdateActivity
+import org.koitharu.kotatsu.settings.backup.PeriodicalBackupService
import javax.inject.Inject
import com.google.android.material.R as materialR
@@ -353,6 +354,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav
requestNotificationsPermission()
}
startService(Intent(this@MainActivity, LocalIndexUpdateService::class.java))
+ startService(Intent(this@MainActivity, PeriodicalBackupService::class.java))
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt
index cc076775e..61b64637a 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt
@@ -68,7 +68,7 @@ class AppBackupAgent : BackupAgent() {
@VisibleForTesting
fun createBackupFile(context: Context, repository: BackupRepository) = runBlocking {
- BackupZipOutput(context).use { backup ->
+ BackupZipOutput.createTemp(context).use { backup ->
backup.put(repository.createIndex())
backup.put(repository.dumpHistory())
backup.put(repository.dumpCategories())
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt
index 654e1656b..62e21aa00 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt
@@ -23,7 +23,7 @@ class BackupViewModel @Inject constructor(
init {
launchLoadingJob {
- val file = BackupZipOutput(context).use { backup ->
+ val file = BackupZipOutput.createTemp(context).use { backup ->
val step = 1f / 6f
backup.put(repository.createIndex())
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt
new file mode 100644
index 000000000..a52324118
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt
@@ -0,0 +1,56 @@
+package org.koitharu.kotatsu.settings.backup
+
+import android.content.Intent
+import dagger.hilt.android.AndroidEntryPoint
+import org.koitharu.kotatsu.core.backup.BackupRepository
+import org.koitharu.kotatsu.core.backup.BackupZipOutput
+import org.koitharu.kotatsu.core.backup.ExternalBackupStorage
+import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.core.ui.CoroutineIntentService
+import java.time.LocalDateTime
+import java.time.temporal.ChronoUnit
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class PeriodicalBackupService : CoroutineIntentService() {
+
+ @Inject
+ lateinit var externalBackupStorage: ExternalBackupStorage
+
+ @Inject
+ lateinit var repository: BackupRepository
+
+ @Inject
+ lateinit var settings: AppSettings
+
+ override suspend fun IntentJobContext.processIntent(intent: Intent) {
+ if (!settings.isPeriodicalBackupEnabled || settings.periodicalBackupDirectory == null) {
+ return
+ }
+ val lastBackupDate = externalBackupStorage.getLastBackupDate()
+ if (lastBackupDate != null && lastBackupDate.plus(settings.periodicalBackupFrequency, ChronoUnit.MILLIS)
+ .isAfter(LocalDateTime.now())
+ ) {
+ return
+ }
+ val output = BackupZipOutput.createTemp(applicationContext)
+ try {
+ output.use { backup ->
+ backup.put(repository.createIndex())
+ backup.put(repository.dumpHistory())
+ backup.put(repository.dumpCategories())
+ backup.put(repository.dumpFavourites())
+ backup.put(repository.dumpBookmarks())
+ backup.put(repository.dumpSources())
+ backup.put(repository.dumpSettings())
+ backup.finish()
+ }
+ externalBackupStorage.put(output.file)
+ externalBackupStorage.trim(settings.periodicalBackupMaxCount)
+ } finally {
+ output.file.delete()
+ }
+ }
+
+ override fun IntentJobContext.onError(error: Throwable) = Unit
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt
index 3e5dfd316..8c855eb99 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt
@@ -14,14 +14,16 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.core.backup.DIR_BACKUPS
+import org.koitharu.kotatsu.core.backup.BackupZipOutput.Companion.DIR_BACKUPS
+import org.koitharu.kotatsu.core.backup.ExternalBackupStorage
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.resolveFile
import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import java.io.File
-import java.text.SimpleDateFormat
+import java.time.format.DateTimeFormatter
+import java.time.format.FormatStyle
import javax.inject.Inject
@AndroidEntryPoint
@@ -29,7 +31,7 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi
ActivityResultCallback {
@Inject
- lateinit var scheduler: PeriodicalBackupWorker.Scheduler
+ lateinit var backupStorage: ExternalBackupStorage
private val outputSelectCall = registerForActivityResult(
ActivityResultContracts.OpenDocumentTree(),
@@ -57,7 +59,7 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi
if (result != null) {
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context?.contentResolver?.takePersistableUriPermission(result, takeFlags)
- settings.periodicalBackupOutput = result
+ settings.periodicalBackupDirectory = result
bindOutputSummary()
}
}
@@ -66,7 +68,7 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi
val preference = findPreference(AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT) ?: return
viewLifecycleScope.launch {
preference.summary = withContext(Dispatchers.Default) {
- val value = settings.periodicalBackupOutput
+ val value = settings.periodicalBackupDirectory
value?.toUserFriendlyString(preference.context) ?: preference.context.run {
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
}.path
@@ -78,11 +80,11 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi
val preference = findPreference(AppSettings.KEY_BACKUP_PERIODICAL_LAST) ?: return
viewLifecycleScope.launch {
val lastDate = withContext(Dispatchers.Default) {
- scheduler.getLastSuccessfulBackup()
+ backupStorage.getLastBackupDate()
}
preference.summary = lastDate?.let {
- val format = SimpleDateFormat.getDateTimeInstance(SimpleDateFormat.MEDIUM, SimpleDateFormat.SHORT)
- preference.context.getString(R.string.last_successful_backup, format.format(it))
+ val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG)
+ preference.context.getString(R.string.last_successful_backup, it.format(formatter))
}
preference.isVisible = lastDate != null
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt
deleted file mode 100644
index 224df7b7b..000000000
--- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt
+++ /dev/null
@@ -1,112 +0,0 @@
-package org.koitharu.kotatsu.settings.backup
-
-import android.content.Context
-import android.os.Build
-import androidx.documentfile.provider.DocumentFile
-import androidx.hilt.work.HiltWorker
-import androidx.work.Constraints
-import androidx.work.CoroutineWorker
-import androidx.work.ExistingPeriodicWorkPolicy
-import androidx.work.PeriodicWorkRequestBuilder
-import androidx.work.WorkInfo
-import androidx.work.WorkManager
-import androidx.work.WorkerParameters
-import androidx.work.await
-import androidx.work.workDataOf
-import dagger.Reusable
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedInject
-import org.koitharu.kotatsu.core.backup.BackupRepository
-import org.koitharu.kotatsu.core.backup.BackupZipOutput
-import org.koitharu.kotatsu.core.prefs.AppSettings
-import org.koitharu.kotatsu.core.util.ext.awaitUniqueWorkInfoByName
-import org.koitharu.kotatsu.core.util.ext.deleteAwait
-import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler
-import java.util.Date
-import java.util.concurrent.TimeUnit
-import javax.inject.Inject
-
-@HiltWorker
-class PeriodicalBackupWorker @AssistedInject constructor(
- @Assisted appContext: Context,
- @Assisted params: WorkerParameters,
- private val repository: BackupRepository,
- private val settings: AppSettings,
-) : CoroutineWorker(appContext, params) {
-
- override suspend fun doWork(): Result {
- val resultData = workDataOf(DATA_TIMESTAMP to Date().time)
- val file = BackupZipOutput(applicationContext).use { backup ->
- backup.put(repository.createIndex())
- backup.put(repository.dumpHistory())
- backup.put(repository.dumpCategories())
- backup.put(repository.dumpFavourites())
- backup.put(repository.dumpBookmarks())
- backup.put(repository.dumpSources())
- backup.put(repository.dumpSettings())
- backup.finish()
- backup.file
- }
- val dirUri = settings.periodicalBackupOutput ?: return Result.success(resultData)
- val target = DocumentFile.fromTreeUri(applicationContext, dirUri)
- ?.createFile("application/zip", file.nameWithoutExtension)
- ?.uri ?: return Result.failure()
- applicationContext.contentResolver.openOutputStream(target, "wt")?.use { output ->
- file.inputStream().copyTo(output)
- } ?: return Result.failure()
- file.deleteAwait()
- return Result.success(resultData)
- }
-
- @Reusable
- class Scheduler @Inject constructor(
- private val workManager: WorkManager,
- private val settings: AppSettings,
- ) : PeriodicWorkScheduler {
-
- override suspend fun schedule() {
- val constraints = Constraints.Builder()
- .setRequiresStorageNotLow(true)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- constraints.setRequiresDeviceIdle(true)
- }
- val request = PeriodicWorkRequestBuilder(
- settings.periodicalBackupFrequency,
- TimeUnit.DAYS,
- ).setConstraints(constraints.build())
- .keepResultsForAtLeast(20, TimeUnit.DAYS)
- .addTag(TAG)
- .build()
- workManager
- .enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, request)
- .await()
- }
-
- override suspend fun unschedule() {
- workManager
- .cancelUniqueWork(TAG)
- .await()
- }
-
- override suspend fun isScheduled(): Boolean {
- return workManager
- .awaitUniqueWorkInfoByName(TAG)
- .any { !it.state.isFinished }
- }
-
- suspend fun getLastSuccessfulBackup(): Date? {
- return workManager
- .awaitUniqueWorkInfoByName(TAG)
- .lastOrNull { x -> x.state == WorkInfo.State.SUCCEEDED }
- ?.outputData
- ?.getLong(DATA_TIMESTAMP, 0)
- ?.let { if (it != 0L) Date(it) else null }
- }
- }
-
- private companion object {
-
- const val TAG = "backups"
- const val DATA_TIMESTAMP = "ts"
- }
-}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/SliderPreference.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/SliderPreference.kt
index 28fbf3aca..8bc7d73bf 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/SliderPreference.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/SliderPreference.kt
@@ -50,6 +50,9 @@ class SliderPreference @JvmOverloads constructor(
valueTo = getFloat(R.styleable.SliderPreference_android_valueTo, valueTo.toFloat()).toInt()
stepSize = getFloat(R.styleable.SliderPreference_android_stepSize, stepSize.toFloat()).toInt()
isTickVisible = getBoolean(R.styleable.SliderPreference_tickVisible, isTickVisible)
+ if (getBoolean(R.styleable.SliderPreference_useSimpleSummaryProvider, false)) {
+ summaryProvider = SimpleSummaryProvider
+ }
}
}
@@ -118,6 +121,11 @@ class SliderPreference @JvmOverloads constructor(
}
}
+ private object SimpleSummaryProvider : SummaryProvider {
+
+ override fun provideSummary(preference: SliderPreference) = preference.value.toString()
+ }
+
private class SavedState : AbsSavedState {
val valueFrom: Int
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/work/WorkScheduleManager.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/work/WorkScheduleManager.kt
index 183eefeb6..794e96401 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/work/WorkScheduleManager.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/work/WorkScheduleManager.kt
@@ -5,7 +5,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
-import org.koitharu.kotatsu.settings.backup.PeriodicalBackupWorker
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
import org.koitharu.kotatsu.tracker.work.TrackWorker
import javax.inject.Inject
@@ -16,7 +15,6 @@ class WorkScheduleManager @Inject constructor(
private val settings: AppSettings,
private val suggestionScheduler: SuggestionsWorker.Scheduler,
private val trackerScheduler: TrackWorker.Scheduler,
- private val periodicalBackupScheduler: PeriodicalBackupWorker.Scheduler,
) : SharedPreferences.OnSharedPreferenceChangeListener {
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
@@ -35,13 +33,6 @@ class WorkScheduleManager @Inject constructor(
isEnabled = settings.isSuggestionsEnabled,
force = key != AppSettings.KEY_SUGGESTIONS,
)
-
- AppSettings.KEY_BACKUP_PERIODICAL_ENABLED,
- AppSettings.KEY_BACKUP_PERIODICAL_FREQUENCY -> updateWorker(
- scheduler = periodicalBackupScheduler,
- isEnabled = settings.isPeriodicalBackupEnabled,
- force = key != AppSettings.KEY_BACKUP_PERIODICAL_ENABLED,
- )
}
}
@@ -50,7 +41,6 @@ class WorkScheduleManager @Inject constructor(
processLifecycleScope.launch(Dispatchers.Default) {
updateWorkerImpl(trackerScheduler, settings.isTrackerEnabled, true) // always force due to adaptive interval
updateWorkerImpl(suggestionScheduler, settings.isSuggestionsEnabled, false)
- updateWorkerImpl(periodicalBackupScheduler, settings.isPeriodicalBackupEnabled, false)
}
}
diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml
index 300ae87ff..fafb1608f 100644
--- a/app/src/main/res/values/attrs.xml
+++ b/app/src/main/res/values/attrs.xml
@@ -21,6 +21,7 @@
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 4cab81d95..e40a23fa8 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -761,4 +761,5 @@
Landscape
"]]>
Access denied (403)
+ Max number of backups
diff --git a/app/src/main/res/xml/pref_backup_periodic.xml b/app/src/main/res/xml/pref_backup_periodic.xml
index 712c15310..68c862656 100644
--- a/app/src/main/res/xml/pref_backup_periodic.xml
+++ b/app/src/main/res/xml/pref_backup_periodic.xml
@@ -10,6 +10,11 @@
android:layout="@layout/preference_toggle_header"
android:title="@string/periodic_backups_enable" />
+
+
-
+