Change periodical backup creation
parent
a7138d23ac
commit
0c56e730fe
@ -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)
|
||||||
|
}
|
||||||
@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue