Change periodical backup creation

This commit is contained in:
Koitharu
2024-11-02 11:44:36 +02:00
parent a7138d23ac
commit 0c56e730fe
27 changed files with 360 additions and 251 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<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) {
val source = mangaRepositoryFactory.create(manga.source)

View File

@@ -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()

View File

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

View File

@@ -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<ParcelableManga>(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 {

View File

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

View File

@@ -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<ActivityMainBinding>(), AppBarOwner, BottomNav
requestNotificationsPermission()
}
startService(Intent(this@MainActivity, LocalIndexUpdateService::class.java))
startService(Intent(this@MainActivity, PeriodicalBackupService::class.java))
}
}

View File

@@ -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())

View File

@@ -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())

View File

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

View File

@@ -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<Uri?> {
@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<Preference>(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<Preference>(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
}

View File

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

View File

@@ -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<SliderPreference> {
override fun provideSummary(preference: SliderPreference) = preference.value.toString()
}
private class SavedState : AbsSavedState {
val valueFrom: Int

View File

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

View File

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

View File

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

View File

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