Restore backups in background

master
Koitharu 1 year ago
parent c37f795dac
commit 008f2d705a
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

@ -55,7 +55,7 @@ class StrictModeNotifier(
.setContentIntent( .setContentIntent(
PendingIntentCompat.getActivity( PendingIntentCompat.getActivity(
context, context,
0, violation.hashCode(),
ShareHelper(context).getShareTextIntent(violation.stackTraceToString()), ShareHelper(context).getShareTextIntent(violation.stackTraceToString()),
0, 0,
false, false,

@ -280,6 +280,10 @@
<service <service
android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService" android:name="org.koitharu.kotatsu.local.ui.LocalIndexUpdateService"
android:label="@string/local_manga_processing" /> android:label="@string/local_manga_processing" />
<service
android:name="org.koitharu.kotatsu.settings.backup.RestoreService"
android:foregroundServiceType="dataSync"
android:label="@string/restore_backup" />
<service <service
android:name="org.koitharu.kotatsu.local.ui.ImportService" android:name="org.koitharu.kotatsu.local.ui.ImportService"
android:foregroundServiceType="dataSync" android:foregroundServiceType="dataSync"

@ -1,11 +1,13 @@
package org.koitharu.kotatsu.core.backup package org.koitharu.kotatsu.core.backup
import androidx.room.withTransaction import androidx.room.withTransaction
import kotlinx.coroutines.flow.FlowCollector
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.progress.Progress
import org.koitharu.kotatsu.parsers.util.json.asTypedList import org.koitharu.kotatsu.parsers.util.json.asTypedList
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.mapJSON import org.koitharu.kotatsu.parsers.util.json.mapJSON
@ -128,9 +130,11 @@ class BackupRepository @Inject constructor(
return if (timestamp == 0L) null else Date(timestamp) return if (timestamp == 0L) null else Date(timestamp)
} }
suspend fun restoreHistory(entry: BackupEntry): CompositeResult { suspend fun restoreHistory(entry: BackupEntry, outProgress: FlowCollector<Progress>?): CompositeResult {
val result = CompositeResult() val result = CompositeResult()
for (item in entry.data.asTypedList<JSONObject>()) { val list = entry.data.asTypedList<JSONObject>()
outProgress?.emit(Progress(progress = 0, total = list.size))
for ((index, item) in list.withIndex()) {
val mangaJson = item.getJSONObject("manga") val mangaJson = item.getJSONObject("manga")
val manga = JsonDeserializer(mangaJson).toMangaEntity() val manga = JsonDeserializer(mangaJson).toMangaEntity()
val tags = mangaJson.getJSONArray("tags").mapJSON { val tags = mangaJson.getJSONArray("tags").mapJSON {
@ -144,6 +148,7 @@ class BackupRepository @Inject constructor(
db.getHistoryDao().upsert(history) db.getHistoryDao().upsert(history)
} }
} }
outProgress?.emit(Progress(progress = index, total = list.size))
} }
return result return result
} }
@ -159,9 +164,11 @@ class BackupRepository @Inject constructor(
return result return result
} }
suspend fun restoreFavourites(entry: BackupEntry): CompositeResult { suspend fun restoreFavourites(entry: BackupEntry, outProgress: FlowCollector<Progress>?): CompositeResult {
val result = CompositeResult() val result = CompositeResult()
for (item in entry.data.asTypedList<JSONObject>()) { val list = entry.data.asTypedList<JSONObject>()
outProgress?.emit(Progress(progress = 0, total = list.size))
for ((index, item) in list.withIndex()) {
val mangaJson = item.getJSONObject("manga") val mangaJson = item.getJSONObject("manga")
val manga = JsonDeserializer(mangaJson).toMangaEntity() val manga = JsonDeserializer(mangaJson).toMangaEntity()
val tags = mangaJson.getJSONArray("tags").mapJSON { val tags = mangaJson.getJSONArray("tags").mapJSON {
@ -175,6 +182,7 @@ class BackupRepository @Inject constructor(
db.getFavouritesDao().upsert(favourite) db.getFavouritesDao().upsert(favourite)
} }
} }
outProgress?.emit(Progress(progress = index, total = list.size))
} }
return result return result
} }

@ -27,6 +27,10 @@ class CompositeResult {
} }
} }
operator fun plusAssign(error: Throwable) {
errors.add(error)
}
operator fun plusAssign(other: CompositeResult) { operator fun plusAssign(other: CompositeResult) {
this.successCount += other.successCount this.successCount += other.successCount
this.errors += other.errors this.errors += other.errors

@ -54,6 +54,7 @@ import org.koitharu.kotatsu.list.ui.config.ListConfigBottomSheet
import org.koitharu.kotatsu.list.ui.config.ListConfigSection import org.koitharu.kotatsu.list.ui.config.ListConfigSection
import org.koitharu.kotatsu.local.ui.ImportDialogFragment import org.koitharu.kotatsu.local.ui.ImportDialogFragment
import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.main.ui.welcome.WelcomeSheet import org.koitharu.kotatsu.main.ui.welcome.WelcomeSheet
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaListFilter
@ -512,6 +513,8 @@ class AppRouter private constructor(
fun suggestionsIntent(context: Context) = Intent(context, SuggestionsActivity::class.java) fun suggestionsIntent(context: Context) = Intent(context, SuggestionsActivity::class.java)
fun homeIntent(context: Context) = Intent(context, MainActivity::class.java)
fun mangaUpdatesIntent(context: Context) = Intent(context, UpdatesActivity::class.java) fun mangaUpdatesIntent(context: Context) = Intent(context, UpdatesActivity::class.java)
fun readerSettingsIntent(context: Context) = fun readerSettingsIntent(context: Context) =
@ -561,9 +564,14 @@ class AppRouter private constructor(
.putExtra(KEY_SOURCE, source.name) .putExtra(KEY_SOURCE, source.name)
} }
const val KEY_DATA = "data"
const val KEY_ENTRIES = "entries"
const val KEY_ERROR = "error"
const val KEY_EXCLUDE = "exclude" const val KEY_EXCLUDE = "exclude"
const val KEY_FILE = "file"
const val KEY_FILTER = "filter" const val KEY_FILTER = "filter"
const val KEY_ID = "id" const val KEY_ID = "id"
const val KEY_INDEX = "index"
const val KEY_LIST_SECTION = "list_section" const val KEY_LIST_SECTION = "list_section"
const val KEY_MANGA = "manga" const val KEY_MANGA = "manga"
const val KEY_MANGA_LIST = "manga_list" const val KEY_MANGA_LIST = "manga_list"
@ -573,12 +581,8 @@ class AppRouter private constructor(
const val KEY_SOURCE = "source" const val KEY_SOURCE = "source"
const val KEY_TAB = "tab" const val KEY_TAB = "tab"
const val KEY_TITLE = "title" const val KEY_TITLE = "title"
const val KEY_USER_AGENT = "user_agent"
const val KEY_URL = "url" const val KEY_URL = "url"
const val KEY_ERROR = "error" const val KEY_USER_AGENT = "user_agent"
const val KEY_FILE = "file"
const val KEY_INDEX = "index"
const val KEY_DATA = "data"
const val ACTION_HISTORY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_HISTORY" const val ACTION_HISTORY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_HISTORY"
const val ACTION_MANAGE_DOWNLOADS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_DOWNLOADS" const val ACTION_MANAGE_DOWNLOADS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_DOWNLOADS"

@ -14,6 +14,7 @@ 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.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -42,6 +43,8 @@ abstract class CoroutineIntentService : BaseService() {
intentJobContext.processIntent(intent) intentJobContext.processIntent(intent)
} }
} }
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) { } catch (e: Throwable) {
e.printStackTraceDebug() e.printStackTraceDebug()
intentJobContext.onError(e) intentJobContext.onError(e)

@ -1,11 +1,14 @@
package org.koitharu.kotatsu.core.util.ext package org.koitharu.kotatsu.core.util.ext
import android.annotation.TargetApi import android.content.ContentResolver
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.OpenableColumns
import androidx.annotation.RequiresApi
import androidx.core.net.toFile
import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import org.koitharu.kotatsu.parsers.util.removeSuffix import org.koitharu.kotatsu.parsers.util.removeSuffix
import java.io.File import java.io.File
@ -31,6 +34,21 @@ fun Uri.resolveFile(context: Context): File? {
) )
} }
fun ContentResolver.getFileDisplayName(uri: Uri): String? = runCatching {
if (uri.isFileUri()) {
return@runCatching uri.toFile().name
}
query(uri, null, null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) {
cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
} else {
null
}
}
}.onFailure { e ->
e.printStackTraceDebug()
}.getOrNull()
private fun getVolumePath(volumeId: String, context: Context): String? { private fun getVolumePath(volumeId: String, context: Context): String? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
getVolumePathForAndroid11AndAbove(volumeId, context) getVolumePathForAndroid11AndAbove(volumeId, context)
@ -63,7 +81,7 @@ private fun getVolumePathBeforeAndroid11(volumeId: String, context: Context): St
it.printStackTraceDebug() it.printStackTraceDebug()
}.getOrNull() }.getOrNull()
@TargetApi(Build.VERSION_CODES.R) @RequiresApi(Build.VERSION_CODES.R)
private fun getVolumePathForAndroid11AndAbove(volumeId: String, context: Context): String? = runCatching { private fun getVolumePathForAndroid11AndAbove(volumeId: String, context: Context): String? = runCatching {
val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager
storageManager.storageVolumes.firstNotNullOfOrNull { volume -> storageManager.storageVolumes.firstNotNullOfOrNull { volume ->

@ -0,0 +1,47 @@
package org.koitharu.kotatsu.core.util.progress
data class Progress(
val progress: Int,
val total: Int,
) : Comparable<Progress> {
val percent: Float
get() = if (total == 0) 0f else progress / total.toFloat()
val isEmpty: Boolean
get() = progress == 0
val isFull: Boolean
get() = progress == total
override fun compareTo(other: Progress): Int = if (total == other.total) {
progress.compareTo(other.progress)
} else {
percent.compareTo(other.percent)
}
operator fun inc() = if (isFull) {
this
} else {
copy(
progress = progress + 1,
total = total,
)
}
operator fun dec() = if (isEmpty) {
this
} else {
copy(
progress = progress - 1,
total = total,
)
}
operator fun plus(child: Progress) = Progress(
progress = progress * child.total + child.progress,
total = total * child.total,
)
fun percentSting() = (percent * 100f).toInt().toString()
}

@ -97,9 +97,9 @@ class AppBackupAgent : BackupAgent() {
} }
try { try {
runBlocking { runBlocking {
backup.getEntry(BackupEntry.Name.HISTORY)?.let { repository.restoreHistory(it) } backup.getEntry(BackupEntry.Name.HISTORY)?.let { repository.restoreHistory(it, null) }
backup.getEntry(BackupEntry.Name.CATEGORIES)?.let { repository.restoreCategories(it) } backup.getEntry(BackupEntry.Name.CATEGORIES)?.let { repository.restoreCategories(it) }
backup.getEntry(BackupEntry.Name.FAVOURITES)?.let { repository.restoreFavourites(it) } backup.getEntry(BackupEntry.Name.FAVOURITES)?.let { repository.restoreFavourites(it, null) }
backup.getEntry(BackupEntry.Name.BOOKMARKS)?.let { repository.restoreBookmarks(it) } backup.getEntry(BackupEntry.Name.BOOKMARKS)?.let { repository.restoreBookmarks(it) }
backup.getEntry(BackupEntry.Name.SOURCES)?.let { repository.restoreSources(it) } backup.getEntry(BackupEntry.Name.SOURCES)?.let { repository.restoreSources(it) }
backup.getEntry(BackupEntry.Name.SETTINGS)?.let { repository.restoreSettings(it) } backup.getEntry(BackupEntry.Name.SETTINGS)?.let { repository.restoreSettings(it) }

@ -4,6 +4,7 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
@ -11,7 +12,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.backup.CompositeResult
import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.AlertDialogFragment import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
@ -23,7 +23,6 @@ import org.koitharu.kotatsu.databinding.DialogRestoreBinding
import java.text.DateFormat import java.text.DateFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import kotlin.math.roundToInt
@AndroidEntryPoint @AndroidEntryPoint
class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnListItemClickListener<BackupEntryModel>, class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnListItemClickListener<BackupEntryModel>,
@ -43,8 +42,6 @@ class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnLis
binding.buttonCancel.setOnClickListener(this) binding.buttonCancel.setOnClickListener(this)
binding.buttonRestore.setOnClickListener(this) binding.buttonRestore.setOnClickListener(this)
viewModel.availableEntries.observe(viewLifecycleOwner, adapter) viewModel.availableEntries.observe(viewLifecycleOwner, adapter)
viewModel.progress.observe(viewLifecycleOwner, this::onProgressChanged)
viewModel.onRestoreDone.observeEvent(viewLifecycleOwner, this::onRestoreDone)
viewModel.onError.observeEvent(viewLifecycleOwner, this::onError) viewModel.onError.observeEvent(viewLifecycleOwner, this::onError)
combine( combine(
viewModel.isLoading, viewModel.isLoading,
@ -63,7 +60,15 @@ class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnLis
override fun onClick(v: View) { override fun onClick(v: View) {
when (v.id) { when (v.id) {
R.id.button_cancel -> dismiss() R.id.button_cancel -> dismiss()
R.id.button_restore -> viewModel.restore() R.id.button_restore -> {
if (startRestoreService()) {
Toast.makeText(v.context, R.string.backup_restored_background, Toast.LENGTH_SHORT).show()
router.closeWelcomeSheet()
dismiss()
} else {
Toast.makeText(v.context, R.string.operation_not_supported, Toast.LENGTH_SHORT).show()
}
}
} }
} }
@ -87,6 +92,14 @@ class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnLis
} }
} }
private fun startRestoreService(): Boolean {
return RestoreService.start(
context ?: return false,
viewModel.uri ?: return false,
viewModel.getCheckedEntries(),
)
}
private fun Date.formatBackupDate(): String { private fun Date.formatBackupDate(): String {
return getString( return getString(
R.string.backup_date_, R.string.backup_date_,
@ -102,46 +115,4 @@ class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnLis
.show() .show()
dismiss() dismiss()
} }
private fun onProgressChanged(value: Float) {
with(requireViewBinding().progressBar) {
isVisible = true
val wasIndeterminate = isIndeterminate
isIndeterminate = value < 0
if (value >= 0) {
setProgressCompat((value * max).roundToInt(), !wasIndeterminate)
}
}
}
private fun onRestoreDone(result: CompositeResult) {
val builder = MaterialAlertDialogBuilder(context ?: return)
when {
result.isEmpty -> {
builder.setTitle(R.string.data_not_restored)
.setMessage(R.string.data_not_restored_text)
}
result.isAllSuccess -> {
builder.setTitle(R.string.data_restored)
.setMessage(R.string.data_restored_success)
}
result.isAllFailed -> builder.setTitle(R.string.error)
.setMessage(
result.failures.map {
it.getDisplayMessage(resources)
}.distinct().joinToString("\n"),
)
else -> builder.setTitle(R.string.data_restored)
.setMessage(R.string.data_restored_with_errors)
}
builder.setPositiveButton(android.R.string.ok, null)
.show()
if (!result.isEmpty && !result.isAllFailed) {
router.closeWelcomeSheet()
}
dismiss()
}
} }

@ -0,0 +1,287 @@
package org.koitharu.kotatsu.settings.backup
import android.annotation.SuppressLint
import android.app.Notification
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.net.Uri
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ErrorReporterReceiver
import org.koitharu.kotatsu.core.backup.BackupEntry
import org.koitharu.kotatsu.core.backup.BackupRepository
import org.koitharu.kotatsu.core.backup.BackupZipInput
import org.koitharu.kotatsu.core.backup.CompositeResult
import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getFileDisplayName
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.core.util.progress.Progress
import org.koitharu.kotatsu.parsers.util.mapToArray
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
import java.io.File
import java.io.FileNotFoundException
import java.util.EnumSet
import javax.inject.Inject
import com.google.android.material.R as materialR
@AndroidEntryPoint
class RestoreService : CoroutineIntentService() {
@Inject
lateinit var repository: BackupRepository
private lateinit var notificationManager: NotificationManagerCompat
override fun onCreate() {
super.onCreate()
notificationManager = NotificationManagerCompat.from(applicationContext)
}
override suspend fun IntentJobContext.processIntent(intent: Intent) {
startForeground(this)
val uri = intent.getStringExtra(AppRouter.KEY_DATA)?.toUriOrNull() ?: throw FileNotFoundException()
val displayName = contentResolver.getFileDisplayName(uri)
val entries = intent.getIntArrayExtra(AppRouter.KEY_ENTRIES)
?.mapTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { BackupEntry.Name.entries[it] }
if (entries.isNullOrEmpty()) {
throw IllegalArgumentException("No entries specified")
}
val result = runInterruptible(Dispatchers.IO) {
val tempFile = File.createTempFile("backup_", ".tmp")
(contentResolver.openInputStream(uri) ?: throw FileNotFoundException()).use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
}
BackupZipInput.from(tempFile)
}.use { backupInput ->
restoreImpl(displayName, backupInput, entries)
}
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
val notification = buildNotification(displayName, result)
notificationManager.notify(TAG, startId, notification)
}
}
override fun IntentJobContext.onError(error: Throwable) {
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
val result = CompositeResult()
result += error
val notification = buildNotification(null, result)
notificationManager.notify(TAG, startId, notification)
}
}
private suspend fun IntentJobContext.restoreImpl(
displayName: String?,
input: BackupZipInput,
entries: Set<BackupEntry.Name>
): CompositeResult {
val result = CompositeResult()
val showNotification = applicationContext.checkNotificationPermission(CHANNEL_ID)
var progress = Progress(0, entries.size)
fun notify(childProgress: Progress? = null) {
if (showNotification) {
val p = childProgress?.let { progress + it } ?: progress
notificationManager.notify(FOREGROUND_NOTIFICATION_ID, buildNotification(displayName, p))
}
}
notify()
if (BackupEntry.Name.HISTORY in entries) {
input.getEntry(BackupEntry.Name.HISTORY)?.let {
flow {
result += repository.restoreHistory(it, this)
}.collect { p ->
notify(p)
}
}
progress++
}
notify()
if (BackupEntry.Name.CATEGORIES in entries) {
input.getEntry(BackupEntry.Name.CATEGORIES)?.let {
result += repository.restoreCategories(it)
}
progress++
}
notify()
if (BackupEntry.Name.FAVOURITES in entries) {
input.getEntry(BackupEntry.Name.FAVOURITES)?.let {
flow {
result += repository.restoreFavourites(it, this)
}.collect { p ->
notify(p)
}
}
}
notify()
if (BackupEntry.Name.BOOKMARKS in entries) {
input.getEntry(BackupEntry.Name.BOOKMARKS)?.let {
result += repository.restoreBookmarks(it)
}
progress++
}
notify()
if (BackupEntry.Name.SOURCES in entries) {
input.getEntry(BackupEntry.Name.SOURCES)?.let {
result += repository.restoreSources(it)
}
progress++
}
notify()
if (BackupEntry.Name.SETTINGS in entries) {
input.getEntry(BackupEntry.Name.SETTINGS)?.let {
result += repository.restoreSettings(it)
}
progress++
}
notify()
return result
}
@SuppressLint("InlinedApi")
private fun startForeground(jobContext: IntentJobContext) {
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.restoring_backup))
.setShowBadge(true)
.setVibrationEnabled(false)
.setSound(null, null)
.setLightsEnabled(false)
.build()
notificationManager.createNotificationChannel(channel)
val notification = jobContext.buildNotification(null, null)
jobContext.setForeground(
FOREGROUND_NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
}
private fun IntentJobContext.buildNotification(fileName: String?, progress: Progress?): Notification {
return NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setContentTitle(getString(R.string.restoring_backup))
.setPriority(NotificationCompat.PRIORITY_LOW)
.setDefaults(0)
.setSilent(true)
.setOngoing(true)
.setProgress(progress?.total ?: 0, progress?.progress ?: 0, progress == null)
.setContentText(
concatStrings(
context = this@RestoreService,
a = fileName,
b = progress?.run { getString(R.string.percent_string_pattern, percentSting()) },
),
)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.addAction(
materialR.drawable.material_ic_clear_black_24dp,
applicationContext.getString(android.R.string.cancel),
getCancelIntent(),
).build()
}
private fun buildNotification(fileName: String?, result: CompositeResult): Notification {
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setDefaults(0)
.setSilent(true)
.setAutoCancel(true)
.setSubText(fileName)
when {
result.isEmpty -> notification.setContentTitle(getString(R.string.data_not_restored))
.setContentText(getString(R.string.data_not_restored_text))
.setSmallIcon(android.R.drawable.stat_notify_error)
result.isAllSuccess -> notification.setContentTitle(getString(R.string.data_restored))
.setContentText(getString(R.string.data_restored_success))
.setSmallIcon(R.drawable.ic_stat_done)
result.isAllFailed -> notification.setContentTitle(getString(R.string.error))
.setContentText(
result.failures.map { it.getDisplayMessage(resources) }.distinct().joinToString("\n"),
)
.setSmallIcon(android.R.drawable.stat_notify_error)
else -> notification.setContentTitle(getString(R.string.data_restored))
.setContentText(getString(R.string.data_restored_with_errors))
.setSmallIcon(R.drawable.ic_stat_done)
}
result.failures.firstOrNull()?.let { error ->
ErrorReporterReceiver.getPendingIntent(applicationContext, error)?.let { reportIntent ->
notification.addAction(
R.drawable.ic_alert_outline,
applicationContext.getString(R.string.report),
reportIntent,
)
}
}
notification.setContentIntent(
PendingIntentCompat.getActivity(
applicationContext,
0,
AppRouter.homeIntent(this),
0,
false,
),
)
return notification.build()
}
private fun concatStrings(context: Context, a: String?, b: String?): String? = when {
a.isNullOrEmpty() && b.isNullOrEmpty() -> null
a.isNullOrEmpty() -> b?.nullIfEmpty()
b.isNullOrEmpty() -> a.nullIfEmpty()
else -> context.getString(R.string.download_summary_pattern, a, b)
}
companion object {
private const val TAG = "restore"
private const val CHANNEL_ID = "restore_backup"
private const val FOREGROUND_NOTIFICATION_ID = 39
fun start(context: Context, uri: Uri, entries: Set<BackupEntry.Name>): Boolean = try {
val intent = Intent(context, RestoreService::class.java)
intent.putExtra(AppRouter.KEY_DATA, uri.toString())
intent.putExtra(AppRouter.KEY_ENTRIES, entries.mapToArray { it.ordinal }.toIntArray())
ContextCompat.startForegroundService(context, intent)
true
} catch (e: Exception) {
e.printStackTraceDebug()
false
}
}
}

@ -10,14 +10,9 @@ import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.backup.BackupEntry import org.koitharu.kotatsu.core.backup.BackupEntry
import org.koitharu.kotatsu.core.backup.BackupRepository import org.koitharu.kotatsu.core.backup.BackupRepository
import org.koitharu.kotatsu.core.backup.BackupZipInput import org.koitharu.kotatsu.core.backup.BackupZipInput
import org.koitharu.kotatsu.core.backup.CompositeResult
import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toUriOrNull import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
import java.io.File import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.util.Date import java.util.Date
@ -32,30 +27,28 @@ class RestoreViewModel @Inject constructor(
@ApplicationContext context: Context, @ApplicationContext context: Context,
) : BaseViewModel() { ) : BaseViewModel() {
private val backupInput = suspendLazy { val uri = savedStateHandle.get<String>(AppRouter.KEY_FILE)?.toUriOrNull()
val uri = savedStateHandle.get<String>(AppRouter.KEY_FILE) private val contentResolver = context.contentResolver
?.toUriOrNull() ?: throw FileNotFoundException()
val contentResolver = context.contentResolver val availableEntries = MutableStateFlow<List<BackupEntryModel>>(emptyList())
val backupDate = MutableStateFlow<Date?>(null)
init {
launchLoadingJob(Dispatchers.Default) {
loadBackupInfo()
}
}
private suspend fun loadBackupInfo() {
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
val tempFile = File.createTempFile("backup_", ".tmp") val tempFile = File.createTempFile("backup_", ".tmp")
(contentResolver.openInputStream(uri) ?: throw FileNotFoundException()).use { input -> (uri?.let { contentResolver.openInputStream(it) } ?: throw FileNotFoundException()).use { input ->
tempFile.outputStream().use { output -> tempFile.outputStream().use { output ->
input.copyTo(output) input.copyTo(output)
} }
} }
BackupZipInput.from(tempFile) BackupZipInput.from(tempFile)
} }.use { backup ->
}
val progress = MutableStateFlow(-1f)
val onRestoreDone = MutableEventFlow<CompositeResult>()
val availableEntries = MutableStateFlow<List<BackupEntryModel>>(emptyList())
val backupDate = MutableStateFlow<Date?>(null)
init {
launchLoadingJob(Dispatchers.Default) {
val backup = backupInput.get()
val entries = backup.entries() val entries = backup.entries()
availableEntries.value = BackupEntry.Name.entries.mapNotNull { entry -> availableEntries.value = BackupEntry.Name.entries.mapNotNull { entry ->
if (entry == BackupEntry.Name.INDEX || entry !in entries) { if (entry == BackupEntry.Name.INDEX || entry !in entries) {
@ -71,15 +64,6 @@ class RestoreViewModel @Inject constructor(
} }
} }
override fun onCleared() {
super.onCleared()
runCatching {
backupInput.peek()?.closeAndDelete()
}.onFailure {
it.printStackTraceDebug()
}
}
fun onItemClick(item: BackupEntryModel) { fun onItemClick(item: BackupEntryModel) {
val map = availableEntries.value.associateByTo(EnumMap(BackupEntry.Name::class.java)) { it.name } val map = availableEntries.value.associateByTo(EnumMap(BackupEntry.Name::class.java)) { it.name }
map[item.name] = item.copy(isChecked = !item.isChecked) map[item.name] = item.copy(isChecked = !item.isChecked)
@ -87,61 +71,10 @@ class RestoreViewModel @Inject constructor(
availableEntries.value = map.values.sortedBy { it.name.ordinal } availableEntries.value = map.values.sortedBy { it.name.ordinal }
} }
fun restore() { fun getCheckedEntries(): Set<BackupEntry.Name> = availableEntries.value
launchLoadingJob { .mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) {
val backup = backupInput.get()
val checkedItems = availableEntries.value.mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) {
if (it.isChecked) it.name else null if (it.isChecked) it.name else null
} }
val result = CompositeResult()
val step = 1f / 6f
progress.value = 0f
if (BackupEntry.Name.HISTORY in checkedItems) {
backup.getEntry(BackupEntry.Name.HISTORY)?.let {
result += repository.restoreHistory(it)
}
}
progress.value += step
if (BackupEntry.Name.CATEGORIES in checkedItems) {
backup.getEntry(BackupEntry.Name.CATEGORIES)?.let {
result += repository.restoreCategories(it)
}
}
progress.value += step
if (BackupEntry.Name.FAVOURITES in checkedItems) {
backup.getEntry(BackupEntry.Name.FAVOURITES)?.let {
result += repository.restoreFavourites(it)
}
}
progress.value += step
if (BackupEntry.Name.BOOKMARKS in checkedItems) {
backup.getEntry(BackupEntry.Name.BOOKMARKS)?.let {
result += repository.restoreBookmarks(it)
}
}
progress.value += step
if (BackupEntry.Name.SOURCES in checkedItems) {
backup.getEntry(BackupEntry.Name.SOURCES)?.let {
result += repository.restoreSources(it)
}
}
progress.value += step
if (BackupEntry.Name.SETTINGS in checkedItems) {
backup.getEntry(BackupEntry.Name.SETTINGS)?.let {
result += repository.restoreSettings(it)
}
}
progress.value = 1f
onRestoreDone.call(result)
}
}
/** /**
* Check for inconsistent user selection * Check for inconsistent user selection

@ -793,4 +793,6 @@
<string name="enable_all_sources_summary">All available manga sources will be enabled permanently</string> <string name="enable_all_sources_summary">All available manga sources will be enabled permanently</string>
<string name="all_sources_enabled">All sources are enabled</string> <string name="all_sources_enabled">All sources are enabled</string>
<string name="reader_info_bar_transparent">Transparent reader information bar</string> <string name="reader_info_bar_transparent">Transparent reader information bar</string>
<string name="backup_restored_background">The backup will be restored in the background</string>
<string name="restoring_backup">Restoring backup</string>
</resources> </resources>

@ -27,7 +27,7 @@ ksp = "2.0.21-1.0.28"
leakcanary = "3.0-alpha-8" leakcanary = "3.0-alpha-8"
lifecycle = "2.8.7" lifecycle = "2.8.7"
markwon = "4.6.2" markwon = "4.6.2"
material = "1.13.0-alpha09" material = "1.13.0-alpha10"
moshi = "1.15.2" moshi = "1.15.2"
okhttp = "4.12.0" okhttp = "4.12.0"
okio = "3.10.2" okio = "3.10.2"

Loading…
Cancel
Save