Change periodical backup creation

master
Koitharu 2 years ago
parent a7138d23ac
commit 0c56e730fe
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 35 targetSdk = 35
versionCode = 683 versionCode = 685
versionName = '7.7-a4' versionName = '7.7-a6'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner'
ksp { ksp {
@ -82,7 +82,7 @@ afterEvaluate {
} }
} }
dependencies { dependencies {
implementation('com.github.KotatsuApp:kotatsu-parsers:1.4') { implementation('com.github.KotatsuApp:kotatsu-parsers:79e1d59482') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }
@ -134,9 +134,10 @@ dependencies {
implementation 'androidx.hilt:hilt-work:1.2.0' implementation 'androidx.hilt:hilt-work:1.2.0'
kapt 'androidx.hilt:hilt-compiler: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-core:3.0.0-rc02'
implementation 'io.coil-kt.coil3:coil-network-okhttp:3.0.0-rc01' implementation 'io.coil-kt.coil3:coil-network-okhttp:3.0.0-rc02'
implementation 'io.coil-kt.coil3:coil-gif:3.0.0-rc01' 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 'org.aomedia.avif.android:avif:1.1.1.14d8e3c4'
implementation 'com.github.KotatsuApp:subsampling-scale-image-view:d1d10a6975' implementation 'com.github.KotatsuApp:subsampling-scale-image-view:d1d10a6975'
implementation 'com.github.solkin:disk-lru-cache:1.4' implementation 'com.github.solkin:disk-lru-cache:1.4'

@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.network
import android.util.Log import android.util.Log
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okio.Buffer import okio.Buffer
import org.koitharu.kotatsu.core.network.CommonHeaders.ACCEPT_ENCODING import org.koitharu.kotatsu.core.network.CommonHeaders.ACCEPT_ENCODING
@ -12,8 +13,11 @@ class CurlLoggingInterceptor(
private val escapeRegex = Regex("([\\[\\]\"])") private val escapeRegex = Regex("([\\[\\]\"])")
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response = chain.proceed(chain.request()).also {
val request = chain.request() logRequest(it.networkResponse?.request ?: it.request)
}
private fun logRequest(request: Request) {
var isCompressed = false var isCompressed = false
val curlCmd = StringBuilder() val curlCmd = StringBuilder()
@ -46,8 +50,6 @@ class CurlLoggingInterceptor(
log("---cURL (" + request.url + ")") log("---cURL (" + request.url + ")")
log(curlCmd.toString()) log(curlCmd.toString())
return chain.proceed(request)
} }
private fun String.escape() = replace(escapeRegex) { match -> private fun String.escape() = replace(escapeRegex) { match ->

@ -266,19 +266,26 @@
tools:node="merge" /> tools:node="merge" />
<service <service
android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync"
android:label="@string/local_manga_processing" />
<service <service
android:name="org.koitharu.kotatsu.local.ui.ImportService" android:name="org.koitharu.kotatsu.settings.backup.PeriodicalBackupService"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync"
android:label="@string/periodic_backups" />
<service <service
android:name="org.koitharu.kotatsu.alternatives.ui.AutoFixService" android:name="org.koitharu.kotatsu.alternatives.ui.AutoFixService"
android:foregroundServiceType="dataSync" /> android:foregroundServiceType="dataSync"
<service android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService" /> android:label="@string/fixing_manga" />
<service
android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService"
android:label="@string/local_manga_processing" />
<service <service
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService" android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
android:label="@string/manga_shelf"
android:permission="android.permission.BIND_REMOTEVIEWS" /> android:permission="android.permission.BIND_REMOTEVIEWS" />
<service <service
android:name="org.koitharu.kotatsu.widget.recent.RecentWidgetService" android:name="org.koitharu.kotatsu.widget.recent.RecentWidgetService"
android:label="@string/recent_manga"
android:permission="android.permission.BIND_REMOTEVIEWS" /> android:permission="android.permission.BIND_REMOTEVIEWS" />
<service <service
android:name="org.koitharu.kotatsu.sync.ui.SyncAuthenticatorService" android:name="org.koitharu.kotatsu.sync.ui.SyncAuthenticatorService"
@ -315,7 +322,8 @@
</service> </service>
<service <service
android:name="org.koitharu.kotatsu.details.service.MangaPrefetchService" android:name="org.koitharu.kotatsu.details.service.MangaPrefetchService"
android:exported="false" /> android:exported="false"
android:label="@string/prefetch_content" />
<provider <provider
android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider" android:name="org.koitharu.kotatsu.search.ui.MangaSuggestionsProvider"

@ -48,25 +48,21 @@ class AutoFixService : CoroutineIntentService() {
notificationManager = NotificationManagerCompat.from(applicationContext) notificationManager = NotificationManagerCompat.from(applicationContext)
} }
override suspend fun processIntent(startId: Int, intent: Intent) { override suspend fun IntentJobContext.processIntent(intent: Intent) {
val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS)) val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS))
startForeground(startId) startForeground(this)
try { for (mangaId in ids) {
for (mangaId in ids) { val result = runCatchingCancellable {
val result = runCatchingCancellable { autoFixUseCase.invoke(mangaId)
autoFixUseCase.invoke(mangaId) }
} if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { val notification = buildNotification(result)
val notification = buildNotification(result) notificationManager.notify(TAG, startId, notification)
notificationManager.notify(TAG, startId, notification)
}
} }
} finally {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
} }
} }
override fun onError(startId: Int, error: Throwable) { override fun IntentJobContext.onError(error: Throwable) {
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
val notification = runBlocking { buildNotification(Result.failure(error)) } val notification = runBlocking { buildNotification(Result.failure(error)) }
notificationManager.notify(TAG, startId, notification) notificationManager.notify(TAG, startId, notification)
@ -74,7 +70,7 @@ class AutoFixService : CoroutineIntentService() {
} }
@SuppressLint("InlinedApi") @SuppressLint("InlinedApi")
private fun startForeground(startId: Int) { private fun startForeground(jobContext: IntentJobContext) {
val title = applicationContext.getString(R.string.fixing_manga) val title = applicationContext.getString(R.string.fixing_manga)
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN) val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_MIN)
.setName(title) .setName(title)
@ -98,12 +94,11 @@ class AutoFixService : CoroutineIntentService() {
.addAction( .addAction(
materialR.drawable.material_ic_clear_black_24dp, materialR.drawable.material_ic_clear_black_24dp,
applicationContext.getString(android.R.string.cancel), applicationContext.getString(android.R.string.cancel),
getCancelIntent(startId), jobContext.getCancelIntent(),
) )
.build() .build()
ServiceCompat.startForeground( jobContext.setForeground(
this,
FOREGROUND_NOTIFICATION_ID, FOREGROUND_NOTIFICATION_ID,
notification, notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,

@ -15,6 +15,7 @@ import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder import coil3.gif.GifDecoder
import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.allowRgb565 import coil3.request.allowRgb565
import coil3.svg.SvgDecoder
import coil3.util.DebugLogger import coil3.util.DebugLogger
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
@ -126,6 +127,7 @@ interface AppModule {
} else { } else {
add(GifDecoder.Factory()) add(GifDecoder.Factory())
} }
add(SvgDecoder.Factory())
add(CbzFetcher.Factory()) add(CbzFetcher.Factory())
add(AvifImageDecoder.Factory()) add(AvifImageDecoder.Factory())
add(FaviconFetcher.Factory(mangaRepositoryFactory)) add(FaviconFetcher.Factory(mangaRepositoryFactory))

@ -0,0 +1,12 @@
package org.koitharu.kotatsu.core.backup
import android.net.Uri
import java.time.LocalDateTime
data class BackupFile(
val uri: Uri,
val dateTime: LocalDateTime,
): Comparable<BackupFile> {
override fun compareTo(other: BackupFile): Int = compareValues(dateTime, other.dateTime)
}

@ -5,10 +5,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okio.Closeable import okio.Closeable
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.zip.ZipOutput import org.koitharu.kotatsu.core.zip.ZipOutput
import java.io.File import java.io.File
import java.time.LocalDate import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.util.Locale import java.util.Locale
import java.util.zip.Deflater import java.util.zip.Deflater
@ -27,20 +29,32 @@ class BackupZipOutput(val file: File) : Closeable {
override fun close() { override fun close() {
output.close() output.close()
} }
}
const val DIR_BACKUPS = "backups"
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) { companion object {
val dir = context.run {
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS) const val DIR_BACKUPS = "backups"
} private val dateTimeFormat = DateTimeFormatter.ofPattern("yyyyMMdd-HHmm")
dir.mkdirs()
val filename = buildString { fun generateFileName(context: Context) = buildString {
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT)) append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
append('_') append('_')
append(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"))) append(LocalDateTime.now().format(dateTimeFormat))
append(".bk.zip") 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)))
}
} }
BackupZipOutput(File(dir, filename))
} }

@ -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<BackupFile> = 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" }
}
}

@ -473,7 +473,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val periodicalBackupFrequency: Long val periodicalBackupFrequency: Long
get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toLongOrNull() ?: 7L 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() get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull()
set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) } 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_RESTORE = "restore"
const val KEY_BACKUP_PERIODICAL_ENABLED = "backup_periodic" const val KEY_BACKUP_PERIODICAL_ENABLED = "backup_periodic"
const val KEY_BACKUP_PERIODICAL_FREQUENCY = "backup_periodic_freq" 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_OUTPUT = "backup_periodic_output"
const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last" const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last"
const val KEY_HISTORY_GROUPING = "history_grouping" const val KEY_HISTORY_GROUPING = "history_grouping"

@ -1,5 +1,6 @@
package org.koitharu.kotatsu.core.ui package org.koitharu.kotatsu.core.ui
import android.app.Notification
import android.app.PendingIntent import android.app.PendingIntent
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
@ -9,11 +10,10 @@ import android.os.PatternMatcher
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.core.app.PendingIntentCompat import androidx.core.app.PendingIntentCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -21,60 +21,104 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import kotlin.coroutines.CoroutineContext
abstract class CoroutineIntentService : BaseService() { abstract class CoroutineIntentService : BaseService() {
private val mutex = Mutex() private val mutex = Mutex()
protected open val dispatcher: CoroutineDispatcher = Dispatchers.Default
final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { final override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId) super.onStartCommand(intent, flags, startId)
val job = launchCoroutine(intent, startId) launchCoroutine(intent, startId)
val receiver = CancelReceiver(job)
ContextCompat.registerReceiver(
this,
receiver,
createIntentFilter(this, startId),
ContextCompat.RECEIVER_NOT_EXPORTED,
)
job.invokeOnCompletion { unregisterReceiver(receiver) }
return START_REDELIVER_INTENT 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 { mutex.withLock {
try { try {
if (intent != null) { if (intent != null) {
withContext(dispatcher) { withContext(Dispatchers.Default) {
processIntent(startId, intent) intentJobContext.processIntent(intent)
} }
} }
} catch (e: Throwable) { } catch (e: Throwable) {
e.printStackTraceDebug() e.printStackTraceDebug()
onError(startId, e) intentJobContext.onError(e)
} finally { } finally {
stopSelf(startId) intentJobContext.stop()
} }
} }
} }
@WorkerThread @WorkerThread
protected abstract suspend fun processIntent(startId: Int, intent: Intent) protected abstract suspend fun IntentJobContext.processIntent(intent: Intent)
@AnyThread @AnyThread
protected abstract fun onError(startId: Int, error: Throwable) protected abstract fun IntentJobContext.onError(error: Throwable)
protected fun getCancelIntent(startId: Int) = PendingIntentCompat.getBroadcast( interface IntentJobContext {
this,
0, val startId: Int
createCancelIntent(this, startId),
PendingIntent.FLAG_UPDATE_CURRENT, fun getCancelIntent(): PendingIntent?
false,
) fun setForeground(id: Int, notification: Notification, serviceType: Int)
}
private fun errorHandler(startId: Int) = CoroutineExceptionHandler { _, throwable ->
throwable.printStackTraceDebug() protected inner class IntentJobContextImpl(
onError(startId, throwable) 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( private class CancelReceiver(

@ -34,7 +34,7 @@ class MangaPrefetchService : CoroutineIntentService() {
@Inject @Inject
lateinit var historyRepository: HistoryRepository lateinit var historyRepository: HistoryRepository
override suspend fun processIntent(startId: Int, intent: Intent) { override suspend fun IntentJobContext.processIntent(intent: Intent) {
when (intent.action) { when (intent.action) {
ACTION_PREFETCH_DETAILS -> prefetchDetails( ACTION_PREFETCH_DETAILS -> prefetchDetails(
manga = intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga manga = intent.getParcelableExtraCompat<ParcelableManga>(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) { private suspend fun prefetchDetails(manga: Manga) {
val source = mangaRepositoryFactory.create(manga.source) val source = mangaRepositoryFactory.create(manga.source)

@ -196,6 +196,9 @@ class MangaIndex(source: String?) {
@Blocking @Blocking
@WorkerThread @WorkerThread
fun read(fileSystem: FileSystem, path: Path): MangaIndex? = runCatchingCancellable { fun read(fileSystem: FileSystem, path: Path): MangaIndex? = runCatchingCancellable {
if (!fileSystem.exists(path)) {
return@runCatchingCancellable null
}
val text = fileSystem.source(path).use { val text = fileSystem.source(path).use {
it.buffer().use { buffer -> it.buffer().use { buffer ->
buffer.readUtf8() buffer.readUtf8()

@ -11,7 +11,6 @@ import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat import androidx.core.app.PendingIntentCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import coil3.ImageLoader import coil3.ImageLoader
import coil3.request.ImageRequest import coil3.request.ImageRequest
@ -48,23 +47,19 @@ class ImportService : CoroutineIntentService() {
notificationManager = NotificationManagerCompat.from(applicationContext) 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" } val uri = requireNotNull(intent.getStringExtra(DATA_URI)?.toUriOrNull()) { "No input uri" }
startForeground() startForeground(this)
try { val result = runCatchingCancellable {
val result = runCatchingCancellable { importer.import(uri).manga
importer.import(uri).manga }
} if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { val notification = buildNotification(result)
val notification = buildNotification(result) notificationManager.notify(TAG, startId, notification)
notificationManager.notify(TAG, startId, notification)
}
} finally {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
} }
} }
override fun onError(startId: Int, error: Throwable) { override fun IntentJobContext.onError(error: Throwable) {
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
val notification = runBlocking { buildNotification(Result.failure(error)) } val notification = runBlocking { buildNotification(Result.failure(error)) }
notificationManager.notify(TAG, startId, notification) notificationManager.notify(TAG, startId, notification)
@ -72,7 +67,7 @@ class ImportService : CoroutineIntentService() {
} }
@SuppressLint("InlinedApi") @SuppressLint("InlinedApi")
private fun startForeground() { private fun startForeground(jobContext: IntentJobContext) {
val title = applicationContext.getString(R.string.importing_manga) val title = applicationContext.getString(R.string.importing_manga)
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT) val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
.setName(title) .setName(title)
@ -95,8 +90,7 @@ class ImportService : CoroutineIntentService() {
.setCategory(NotificationCompat.CATEGORY_PROGRESS) .setCategory(NotificationCompat.CATEGORY_PROGRESS)
.build() .build()
ServiceCompat.startForeground( jobContext.setForeground(
this,
FOREGROUND_NOTIFICATION_ID, FOREGROUND_NOTIFICATION_ID,
notification, notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,

@ -1,12 +1,13 @@
package org.koitharu.kotatsu.local.ui package org.koitharu.kotatsu.local.ui
import android.annotation.SuppressLint
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo
import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@ -42,21 +43,17 @@ class LocalChaptersRemoveService : CoroutineIntentService() {
super.onDestroy() super.onDestroy()
} }
override suspend fun processIntent(startId: Int, intent: Intent) { override suspend fun IntentJobContext.processIntent(intent: Intent) {
val manga = intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga ?: return val manga = intent.getParcelableExtraCompat<ParcelableManga>(EXTRA_MANGA)?.manga ?: return
val chaptersIds = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toSet() ?: return val chaptersIds = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toSet() ?: return
startForeground() startForeground(this)
try { val mangaWithChapters = localMangaRepository.getDetails(manga)
val mangaWithChapters = localMangaRepository.getDetails(manga) localMangaRepository.deleteChapters(mangaWithChapters, chaptersIds)
localMangaRepository.deleteChapters(mangaWithChapters, chaptersIds) localStorageChanges.emit(LocalManga(localMangaRepository.getDetails(manga)))
localStorageChanges.emit(LocalManga(localMangaRepository.getDetails(manga)))
} finally {
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
}
} }
override fun onError(startId: Int, error: Throwable) { override fun IntentJobContext.onError(error: Throwable) {
val notification = NotificationCompat.Builder(this, CHANNEL_ID) val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setContentTitle(getString(R.string.error_occurred)) .setContentTitle(getString(R.string.error_occurred))
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setDefaults(0) .setDefaults(0)
@ -64,13 +61,14 @@ class LocalChaptersRemoveService : CoroutineIntentService() {
.setContentText(error.getDisplayMessage(resources)) .setContentText(error.getDisplayMessage(resources))
.setSmallIcon(android.R.drawable.stat_notify_error) .setSmallIcon(android.R.drawable.stat_notify_error)
.setAutoCancel(true) .setAutoCancel(true)
.setContentIntent(ErrorReporterReceiver.getPendingIntent(this, error)) .setContentIntent(ErrorReporterReceiver.getPendingIntent(applicationContext, error))
.build() .build()
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(NOTIFICATION_ID + startId, notification) 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 title = getString(R.string.local_manga_processing)
val manager = NotificationManagerCompat.from(this) val manager = NotificationManagerCompat.from(this)
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW) val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
@ -92,7 +90,7 @@ class LocalChaptersRemoveService : CoroutineIntentService() {
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED) .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
.setOngoing(false) .setOngoing(false)
.build() .build()
startForeground(NOTIFICATION_ID, notification) jobContext.setForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} }
companion object { companion object {

@ -12,9 +12,9 @@ class LocalIndexUpdateService : CoroutineIntentService() {
@Inject @Inject
lateinit var localMangaIndex: LocalMangaIndex lateinit var localMangaIndex: LocalMangaIndex
override suspend fun processIntent(startId: Int, intent: Intent) { override suspend fun IntentJobContext.processIntent(intent: Intent) {
localMangaIndex.update() localMangaIndex.update()
} }
override fun onError(startId: Int, error: Throwable) = Unit override fun IntentJobContext.onError(error: Throwable) = Unit
} }

@ -72,6 +72,7 @@ import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.SettingsActivity
import org.koitharu.kotatsu.settings.about.AppUpdateActivity import org.koitharu.kotatsu.settings.about.AppUpdateActivity
import org.koitharu.kotatsu.settings.backup.PeriodicalBackupService
import javax.inject.Inject import javax.inject.Inject
import com.google.android.material.R as materialR import com.google.android.material.R as materialR
@ -353,6 +354,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
requestNotificationsPermission() requestNotificationsPermission()
} }
startService(Intent(this@MainActivity, LocalIndexUpdateService::class.java)) startService(Intent(this@MainActivity, LocalIndexUpdateService::class.java))
startService(Intent(this@MainActivity, PeriodicalBackupService::class.java))
} }
} }

@ -68,7 +68,7 @@ class AppBackupAgent : BackupAgent() {
@VisibleForTesting @VisibleForTesting
fun createBackupFile(context: Context, repository: BackupRepository) = runBlocking { fun createBackupFile(context: Context, repository: BackupRepository) = runBlocking {
BackupZipOutput(context).use { backup -> BackupZipOutput.createTemp(context).use { backup ->
backup.put(repository.createIndex()) backup.put(repository.createIndex())
backup.put(repository.dumpHistory()) backup.put(repository.dumpHistory())
backup.put(repository.dumpCategories()) backup.put(repository.dumpCategories())

@ -23,7 +23,7 @@ class BackupViewModel @Inject constructor(
init { init {
launchLoadingJob { launchLoadingJob {
val file = BackupZipOutput(context).use { backup -> val file = BackupZipOutput.createTemp(context).use { backup ->
val step = 1f / 6f val step = 1f / 6f
backup.put(repository.createIndex()) backup.put(repository.createIndex())

@ -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
}

@ -14,14 +14,16 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R 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.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.resolveFile import org.koitharu.kotatsu.core.util.ext.resolveFile
import org.koitharu.kotatsu.core.util.ext.tryLaunch import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@ -29,7 +31,7 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi
ActivityResultCallback<Uri?> { ActivityResultCallback<Uri?> {
@Inject @Inject
lateinit var scheduler: PeriodicalBackupWorker.Scheduler lateinit var backupStorage: ExternalBackupStorage
private val outputSelectCall = registerForActivityResult( private val outputSelectCall = registerForActivityResult(
ActivityResultContracts.OpenDocumentTree(), ActivityResultContracts.OpenDocumentTree(),
@ -57,7 +59,7 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi
if (result != null) { if (result != null) {
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context?.contentResolver?.takePersistableUriPermission(result, takeFlags) context?.contentResolver?.takePersistableUriPermission(result, takeFlags)
settings.periodicalBackupOutput = result settings.periodicalBackupDirectory = result
bindOutputSummary() bindOutputSummary()
} }
} }
@ -66,7 +68,7 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT) ?: return val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT) ?: return
viewLifecycleScope.launch { viewLifecycleScope.launch {
preference.summary = withContext(Dispatchers.Default) { preference.summary = withContext(Dispatchers.Default) {
val value = settings.periodicalBackupOutput val value = settings.periodicalBackupDirectory
value?.toUserFriendlyString(preference.context) ?: preference.context.run { value?.toUserFriendlyString(preference.context) ?: preference.context.run {
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS) getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
}.path }.path
@ -78,11 +80,11 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_LAST) ?: return val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_LAST) ?: return
viewLifecycleScope.launch { viewLifecycleScope.launch {
val lastDate = withContext(Dispatchers.Default) { val lastDate = withContext(Dispatchers.Default) {
scheduler.getLastSuccessfulBackup() backupStorage.getLastBackupDate()
} }
preference.summary = lastDate?.let { preference.summary = lastDate?.let {
val format = SimpleDateFormat.getDateTimeInstance(SimpleDateFormat.MEDIUM, SimpleDateFormat.SHORT) val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG)
preference.context.getString(R.string.last_successful_backup, format.format(it)) preference.context.getString(R.string.last_successful_backup, it.format(formatter))
} }
preference.isVisible = lastDate != null preference.isVisible = lastDate != 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<PeriodicalBackupWorker>(
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"
}
}

@ -50,6 +50,9 @@ class SliderPreference @JvmOverloads constructor(
valueTo = getFloat(R.styleable.SliderPreference_android_valueTo, valueTo.toFloat()).toInt() valueTo = getFloat(R.styleable.SliderPreference_android_valueTo, valueTo.toFloat()).toInt()
stepSize = getFloat(R.styleable.SliderPreference_android_stepSize, stepSize.toFloat()).toInt() stepSize = getFloat(R.styleable.SliderPreference_android_stepSize, stepSize.toFloat()).toInt()
isTickVisible = getBoolean(R.styleable.SliderPreference_tickVisible, isTickVisible) 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<SliderPreference> {
override fun provideSummary(preference: SliderPreference) = preference.value.toString()
}
private class SavedState : AbsSavedState { private class SavedState : AbsSavedState {
val valueFrom: Int val valueFrom: Int

@ -5,7 +5,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope 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.suggestions.ui.SuggestionsWorker
import org.koitharu.kotatsu.tracker.work.TrackWorker import org.koitharu.kotatsu.tracker.work.TrackWorker
import javax.inject.Inject import javax.inject.Inject
@ -16,7 +15,6 @@ class WorkScheduleManager @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
private val suggestionScheduler: SuggestionsWorker.Scheduler, private val suggestionScheduler: SuggestionsWorker.Scheduler,
private val trackerScheduler: TrackWorker.Scheduler, private val trackerScheduler: TrackWorker.Scheduler,
private val periodicalBackupScheduler: PeriodicalBackupWorker.Scheduler,
) : SharedPreferences.OnSharedPreferenceChangeListener { ) : SharedPreferences.OnSharedPreferenceChangeListener {
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
@ -35,13 +33,6 @@ class WorkScheduleManager @Inject constructor(
isEnabled = settings.isSuggestionsEnabled, isEnabled = settings.isSuggestionsEnabled,
force = key != AppSettings.KEY_SUGGESTIONS, 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) { processLifecycleScope.launch(Dispatchers.Default) {
updateWorkerImpl(trackerScheduler, settings.isTrackerEnabled, true) // always force due to adaptive interval updateWorkerImpl(trackerScheduler, settings.isTrackerEnabled, true) // always force due to adaptive interval
updateWorkerImpl(suggestionScheduler, settings.isSuggestionsEnabled, false) updateWorkerImpl(suggestionScheduler, settings.isSuggestionsEnabled, false)
updateWorkerImpl(periodicalBackupScheduler, settings.isPeriodicalBackupEnabled, false)
} }
} }

@ -21,6 +21,7 @@
<attr name="android:valueTo" /> <attr name="android:valueTo" />
<attr name="android:stepSize" /> <attr name="android:stepSize" />
<attr name="tickVisible" /> <attr name="tickVisible" />
<attr name="useSimpleSummaryProvider" />
</declare-styleable> </declare-styleable>
<declare-styleable name="ListItemTextView"> <declare-styleable name="ListItemTextView">

@ -761,4 +761,5 @@
<string name="landscape">Landscape</string> <string name="landscape">Landscape</string>
<string name="breadcrumbs_separator" translatable="false"><![CDATA[" > "]]></string> <string name="breadcrumbs_separator" translatable="false"><![CDATA[" > "]]></string>
<string name="access_denied_403">Access denied (403)</string> <string name="access_denied_403">Access denied (403)</string>
<string name="max_backups_count">Max number of backups</string>
</resources> </resources>

@ -10,6 +10,11 @@
android:layout="@layout/preference_toggle_header" android:layout="@layout/preference_toggle_header"
android:title="@string/periodic_backups_enable" /> android:title="@string/periodic_backups_enable" />
<Preference
android:dependency="backup_periodic"
android:key="backup_periodic_output"
android:title="@string/backups_output_directory" />
<ListPreference <ListPreference
android:defaultValue="7" android:defaultValue="7"
android:dependency="backup_periodic" android:dependency="backup_periodic"
@ -19,10 +24,14 @@
android:title="@string/backup_frequency" android:title="@string/backup_frequency"
app:useSimpleSummaryProvider="true" /> app:useSimpleSummaryProvider="true" />
<Preference <org.koitharu.kotatsu.settings.utils.SliderPreference
android:dependency="backup_periodic" android:key="backup_periodic_count"
android:key="backup_periodic_output" android:stepSize="1"
android:title="@string/backups_output_directory" /> android:title="@string/max_backups_count"
android:valueFrom="1"
android:valueTo="32"
app:defaultValue="10"
app:useSimpleSummaryProvider="true" />
<Preference <Preference
android:dependency="backup_periodic" android:dependency="backup_periodic"

@ -4,7 +4,7 @@ buildscript {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:8.7.1' classpath 'com.android.tools.build:gradle:8.7.2'
classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.20' classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.20'
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.52' classpath 'com.google.dagger:hilt-android-gradle-plugin:2.52'
classpath 'com.google.devtools.ksp:symbol-processing-gradle-plugin:2.0.20-1.0.25' classpath 'com.google.devtools.ksp:symbol-processing-gradle-plugin:2.0.20-1.0.25'

Loading…
Cancel
Save