From 0c56e730feca77cb89b6e478536c6af432d25783 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Sat, 2 Nov 2024 11:44:36 +0200 Subject: [PATCH] Change periodical backup creation --- app/build.gradle | 13 +- .../core/network/CurlLoggingInterceptor.kt | 10 +- app/src/main/AndroidManifest.xml | 20 +++- .../kotatsu/alternatives/ui/AutoFixService.kt | 31 ++--- .../org/koitharu/kotatsu/core/AppModule.kt | 2 + .../kotatsu/core/backup/BackupFile.kt | 12 ++ .../kotatsu/core/backup/BackupZipOutput.kt | 42 ++++--- .../core/backup/ExternalBackupStorage.kt | 75 ++++++++++++ .../kotatsu/core/prefs/AppSettings.kt | 6 +- .../kotatsu/core/ui/CoroutineIntentService.kt | 102 +++++++++++----- .../details/service/MangaPrefetchService.kt | 4 +- .../koitharu/kotatsu/local/data/MangaIndex.kt | 3 + .../kotatsu/local/ui/ImportService.kt | 28 ++--- .../local/ui/LocalChaptersRemoveService.kt | 28 ++--- .../local/ui/LocalIndexUpdateService.kt | 4 +- .../koitharu/kotatsu/main/ui/MainActivity.kt | 2 + .../kotatsu/settings/backup/AppBackupAgent.kt | 2 +- .../settings/backup/BackupViewModel.kt | 2 +- .../backup/PeriodicalBackupService.kt | 56 +++++++++ .../PeriodicalBackupSettingsFragment.kt | 18 +-- .../settings/backup/PeriodicalBackupWorker.kt | 112 ------------------ .../settings/utils/SliderPreference.kt | 8 ++ .../settings/work/WorkScheduleManager.kt | 10 -- app/src/main/res/values/attrs.xml | 1 + app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/pref_backup_periodic.xml | 17 ++- build.gradle | 2 +- 27 files changed, 360 insertions(+), 251 deletions(-) create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupFile.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/core/backup/ExternalBackupStorage.kt create mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt delete mode 100644 app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupWorker.kt 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" /> + + - +