Backup and restore user data
parent
03dbd86363
commit
d135898b49
@ -0,0 +1,51 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.utils.MutableZipFile
|
||||
import org.koitharu.kotatsu.utils.ext.format
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
|
||||
class BackupArchive(file: File) : MutableZipFile(file) {
|
||||
|
||||
init {
|
||||
if (!dir.exists()) {
|
||||
dir.mkdirs()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun put(entry: BackupEntry) {
|
||||
put(entry.name, entry.data.toString(2))
|
||||
}
|
||||
|
||||
suspend fun getEntry(name: String): BackupEntry {
|
||||
val json = withContext(Dispatchers.Default) {
|
||||
JSONArray(getContent(name))
|
||||
}
|
||||
return BackupEntry(name, json)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val DIR_BACKUPS = "backups"
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
suspend fun createNew(context: Context): BackupArchive = withContext(Dispatchers.IO) {
|
||||
val dir = context.run {
|
||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
||||
}
|
||||
dir.mkdirs()
|
||||
val filename = buildString {
|
||||
append(context.getString(R.string.app_name).toLowerCase(Locale.ROOT))
|
||||
append('_')
|
||||
append(Date().format("ddMMyyyy"))
|
||||
append(".bak")
|
||||
}
|
||||
BackupArchive(File(dir, filename))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import org.json.JSONArray
|
||||
|
||||
data class BackupEntry(
|
||||
val name: String,
|
||||
val data: JSONArray
|
||||
) {
|
||||
|
||||
companion object Names {
|
||||
|
||||
const val INDEX = "index"
|
||||
const val HISTORY = "history"
|
||||
const val CATEGORIES = "categories"
|
||||
const val FAVOURITES = "favourites"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,130 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.entity.*
|
||||
|
||||
class BackupRepository(private val db: MangaDatabase) {
|
||||
|
||||
suspend fun dumpHistory(): BackupEntry {
|
||||
var offset = 0
|
||||
val entry = BackupEntry(BackupEntry.HISTORY, JSONArray())
|
||||
while (true) {
|
||||
val history = db.historyDao.findAll(offset, PAGE_SIZE)
|
||||
if (history.isEmpty()) {
|
||||
break
|
||||
}
|
||||
offset += history.size
|
||||
for (item in history) {
|
||||
val manga = item.manga.toJson()
|
||||
val tags = JSONArray()
|
||||
item.tags.forEach { tags.put(it.toJson()) }
|
||||
manga.put("tags", tags)
|
||||
val json = item.history.toJson()
|
||||
json.put("manga", manga)
|
||||
entry.data.put(json)
|
||||
}
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
suspend fun dumpCategories(): BackupEntry {
|
||||
val entry = BackupEntry(BackupEntry.CATEGORIES, JSONArray())
|
||||
val categories = db.favouriteCategoriesDao.findAll()
|
||||
for (item in categories) {
|
||||
entry.data.put(item.toJson())
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
suspend fun dumpFavourites(): BackupEntry {
|
||||
var offset = 0
|
||||
val entry = BackupEntry(BackupEntry.FAVOURITES, JSONArray())
|
||||
while (true) {
|
||||
val favourites = db.favouritesDao.findAll(offset, PAGE_SIZE)
|
||||
if (favourites.isEmpty()) {
|
||||
break
|
||||
}
|
||||
offset += favourites.size
|
||||
for (item in favourites) {
|
||||
val manga = item.manga.toJson()
|
||||
val tags = JSONArray()
|
||||
item.tags.forEach { tags.put(it.toJson()) }
|
||||
manga.put("tags", tags)
|
||||
val json = item.favourite.toJson()
|
||||
json.put("manga", manga)
|
||||
entry.data.put(json)
|
||||
}
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
suspend fun createIndex(): BackupEntry {
|
||||
val entry = BackupEntry(BackupEntry.INDEX, JSONArray())
|
||||
val json = JSONObject()
|
||||
json.put("app_id", BuildConfig.APPLICATION_ID)
|
||||
json.put("app_version", BuildConfig.VERSION_CODE)
|
||||
json.put("created_at", System.currentTimeMillis())
|
||||
entry.data.put(json)
|
||||
return entry
|
||||
}
|
||||
|
||||
private fun MangaEntity.toJson(): JSONObject {
|
||||
val jo = JSONObject()
|
||||
jo.put("id", id)
|
||||
jo.put("title", title)
|
||||
jo.put("alt_title", altTitle)
|
||||
jo.put("url", url)
|
||||
jo.put("rating", rating)
|
||||
jo.put("cover_url", coverUrl)
|
||||
jo.put("large_cover_url", largeCoverUrl)
|
||||
jo.put("state", state)
|
||||
jo.put("author", author)
|
||||
jo.put("source", source)
|
||||
return jo
|
||||
}
|
||||
|
||||
private fun TagEntity.toJson(): JSONObject {
|
||||
val jo = JSONObject()
|
||||
jo.put("id", id)
|
||||
jo.put("title", title)
|
||||
jo.put("key", key)
|
||||
jo.put("source", source)
|
||||
return jo
|
||||
}
|
||||
|
||||
private fun HistoryEntity.toJson(): JSONObject {
|
||||
val jo = JSONObject()
|
||||
jo.put("manga_id", mangaId)
|
||||
jo.put("created_at", createdAt)
|
||||
jo.put("updated_at", updatedAt)
|
||||
jo.put("chapter_id", chapterId)
|
||||
jo.put("page", page)
|
||||
jo.put("scroll", scroll)
|
||||
return jo
|
||||
}
|
||||
|
||||
private fun FavouriteCategoryEntity.toJson(): JSONObject {
|
||||
val jo = JSONObject()
|
||||
jo.put("category_id", categoryId)
|
||||
jo.put("created_at", createdAt)
|
||||
jo.put("sort_key", sortKey)
|
||||
jo.put("title", title)
|
||||
return jo
|
||||
}
|
||||
|
||||
private fun FavouriteEntity.toJson(): JSONObject {
|
||||
val jo = JSONObject()
|
||||
jo.put("manga_id", mangaId)
|
||||
jo.put("category_id", categoryId)
|
||||
jo.put("created_at", createdAt)
|
||||
return jo
|
||||
}
|
||||
|
||||
private companion object {
|
||||
|
||||
const val PAGE_SIZE = 10
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
class CompositeResult {
|
||||
|
||||
private var successCount: Int = 0
|
||||
private val errors = ArrayList<Throwable?>()
|
||||
|
||||
val size: Int
|
||||
get() = successCount + errors.size
|
||||
|
||||
val failures: List<Throwable>
|
||||
get() = errors.filterNotNull()
|
||||
|
||||
val isAllSuccess: Boolean
|
||||
get() = errors.none { it != null }
|
||||
|
||||
val isAllFailed: Boolean
|
||||
get() = successCount == 0 && errors.isNotEmpty()
|
||||
|
||||
operator fun plusAssign(result: Result<*>) {
|
||||
when {
|
||||
result.isSuccess -> successCount++
|
||||
result.isFailure -> errors.add(result.exceptionOrNull())
|
||||
}
|
||||
}
|
||||
|
||||
operator fun plusAssign(other: CompositeResult) {
|
||||
this.successCount += other.successCount
|
||||
this.errors += other.errors
|
||||
}
|
||||
|
||||
operator fun plus(other: CompositeResult): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
result.successCount = this.successCount + other.successCount
|
||||
result.errors.addAll(this.errors)
|
||||
result.errors.addAll(other.errors)
|
||||
return result
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,105 @@
|
||||
package org.koitharu.kotatsu.core.backup
|
||||
|
||||
import androidx.room.withTransaction
|
||||
import org.json.JSONObject
|
||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||
import org.koitharu.kotatsu.core.db.entity.*
|
||||
import org.koitharu.kotatsu.utils.ext.getStringOrNull
|
||||
import org.koitharu.kotatsu.utils.ext.iterator
|
||||
import org.koitharu.kotatsu.utils.ext.map
|
||||
|
||||
class RestoreRepository(private val db: MangaDatabase) {
|
||||
|
||||
suspend fun upsertHistory(entry: BackupEntry): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
for (item in entry.data) {
|
||||
val mangaJson = item.getJSONObject("manga")
|
||||
val manga = parseManga(mangaJson)
|
||||
val tags = mangaJson.getJSONArray("tags").map {
|
||||
parseTag(it)
|
||||
}
|
||||
val history = parseHistory(item)
|
||||
result += runCatching {
|
||||
db.withTransaction {
|
||||
db.mangaDao.upsert(manga, tags)
|
||||
db.tagsDao.upsert(tags)
|
||||
db.historyDao.upsert(history)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun upsertCategories(entry: BackupEntry): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
for (item in entry.data) {
|
||||
val category = parseCategory(item)
|
||||
result += runCatching {
|
||||
db.favouriteCategoriesDao.upsert(category)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun upsertFavourites(entry: BackupEntry): CompositeResult {
|
||||
val result = CompositeResult()
|
||||
for (item in entry.data) {
|
||||
val mangaJson = item.getJSONObject("manga")
|
||||
val manga = parseManga(mangaJson)
|
||||
val tags = mangaJson.getJSONArray("tags").map {
|
||||
parseTag(it)
|
||||
}
|
||||
val favourite = parseFavourite(item)
|
||||
result += runCatching {
|
||||
db.withTransaction {
|
||||
db.mangaDao.upsert(manga, tags)
|
||||
db.tagsDao.upsert(tags)
|
||||
db.favouritesDao.upsert(favourite)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun parseManga(json: JSONObject) = MangaEntity(
|
||||
id = json.getLong("id"),
|
||||
title = json.getString("title"),
|
||||
altTitle = json.getStringOrNull("alt_title"),
|
||||
url = json.getString("url"),
|
||||
rating = json.getDouble("rating").toFloat(),
|
||||
coverUrl = json.getString("cover_url"),
|
||||
largeCoverUrl = json.getStringOrNull("large_cover_url"),
|
||||
state = json.getStringOrNull("state"),
|
||||
author = json.getStringOrNull("author"),
|
||||
source = json.getString("source")
|
||||
)
|
||||
|
||||
private fun parseTag(json: JSONObject) = TagEntity(
|
||||
id = json.getLong("id"),
|
||||
title = json.getString("title"),
|
||||
key = json.getString("key"),
|
||||
source = json.getString("source")
|
||||
)
|
||||
|
||||
private fun parseHistory(json: JSONObject) = HistoryEntity(
|
||||
mangaId = json.getLong("manga_id"),
|
||||
createdAt = json.getLong("created_at"),
|
||||
updatedAt = json.getLong("updated_at"),
|
||||
chapterId = json.getLong("chapter_id"),
|
||||
page = json.getInt("page"),
|
||||
scroll = json.getDouble("scroll").toFloat()
|
||||
)
|
||||
|
||||
private fun parseCategory(json: JSONObject) = FavouriteCategoryEntity(
|
||||
categoryId = json.getInt("category_id"),
|
||||
createdAt = json.getLong("created_at"),
|
||||
sortKey = json.getInt("sort_key"),
|
||||
title = json.getString("title")
|
||||
)
|
||||
|
||||
private fun parseFavourite(json: JSONObject) = FavouriteEntity(
|
||||
mangaId = json.getLong("manga_id"),
|
||||
categoryId = json.getLong("category_id"),
|
||||
createdAt = json.getLong("created_at")
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
package org.koitharu.kotatsu.ui.settings.backup
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isVisible
|
||||
import kotlinx.android.synthetic.main.dialog_progress.*
|
||||
import moxy.ktx.moxyPresenter
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.ui.base.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.utils.ShareHelper
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.utils.progress.Progress
|
||||
import java.io.File
|
||||
|
||||
class BackupDialogFragment : AlertDialogFragment(R.layout.dialog_progress), BackupView {
|
||||
|
||||
@Suppress("unused")
|
||||
private val presenter by moxyPresenter {
|
||||
BackupPresenter(get())
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
textView_title.setText(R.string.create_backup)
|
||||
textView_subtitle.setText(R.string.processing_)
|
||||
}
|
||||
|
||||
override fun onBuildDialog(builder: AlertDialog.Builder) {
|
||||
builder.setCancelable(false)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
}
|
||||
|
||||
override fun onError(e: Throwable) {
|
||||
AlertDialog.Builder(context ?: return)
|
||||
.setNegativeButton(R.string.close, null)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(e.getDisplayMessage(resources))
|
||||
.show()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
override fun onLoadingStateChanged(isLoading: Boolean) = Unit
|
||||
|
||||
override fun onProgressChanged(progress: Progress?) {
|
||||
with(progressBar) {
|
||||
isVisible = true
|
||||
isIndeterminate = progress == null
|
||||
if (progress != null) {
|
||||
this.max = progress.total
|
||||
this.progress = progress.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBackupDone(file: File) {
|
||||
ShareHelper.shareBackup(context ?: return, file)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val TAG = "BackupDialogFragment"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
package org.koitharu.kotatsu.ui.settings.backup
|
||||
|
||||
import org.koin.core.component.get
|
||||
import org.koitharu.kotatsu.core.backup.BackupArchive
|
||||
import org.koitharu.kotatsu.core.backup.BackupRepository
|
||||
import org.koitharu.kotatsu.ui.base.BasePresenter
|
||||
import org.koitharu.kotatsu.utils.progress.Progress
|
||||
|
||||
class BackupPresenter(
|
||||
private val repository: BackupRepository
|
||||
) : BasePresenter<BackupView>() {
|
||||
|
||||
override fun onFirstViewAttach() {
|
||||
super.onFirstViewAttach()
|
||||
launchLoadingJob {
|
||||
viewState.onProgressChanged(null)
|
||||
val backup = BackupArchive.createNew(get())
|
||||
backup.put(repository.createIndex())
|
||||
|
||||
viewState.onProgressChanged(Progress(0, 3))
|
||||
backup.put(repository.dumpHistory())
|
||||
|
||||
viewState.onProgressChanged(Progress(1, 3))
|
||||
backup.put(repository.dumpCategories())
|
||||
|
||||
viewState.onProgressChanged(Progress(2, 3))
|
||||
backup.put(repository.dumpFavourites())
|
||||
|
||||
viewState.onProgressChanged(Progress(3, 3))
|
||||
backup.flush()
|
||||
viewState.onProgressChanged(null)
|
||||
backup.cleanup()
|
||||
viewState.onBackupDone(backup.file)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
package org.koitharu.kotatsu.ui.settings.backup
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.activity.result.ActivityResultCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.preference.Preference
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.android.synthetic.main.fragment_list.*
|
||||
import org.koitharu.kotatsu.BuildConfig
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||
import org.koitharu.kotatsu.ui.base.BasePreferenceFragment
|
||||
|
||||
class BackupSettingsFragment : BasePreferenceFragment(R.string.backup_restore),
|
||||
ActivityResultCallback<Uri> {
|
||||
|
||||
private val backupSelectCall = registerForActivityResult(
|
||||
ActivityResultContracts.OpenDocument(),
|
||||
this
|
||||
)
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
addPreferencesFromResource(R.xml.pref_backup)
|
||||
}
|
||||
|
||||
override fun onPreferenceTreeClick(preference: Preference): Boolean {
|
||||
return when (preference.key) {
|
||||
AppSettings.KEY_BACKUP -> {
|
||||
BackupDialogFragment().show(childFragmentManager, BackupDialogFragment.TAG)
|
||||
true
|
||||
}
|
||||
AppSettings.KEY_RESTORE -> {
|
||||
try {
|
||||
backupSelectCall.launch(arrayOf("*/*"))
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
Snackbar.make(
|
||||
recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> super.onPreferenceTreeClick(preference)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onActivityResult(result: Uri?) {
|
||||
RestoreDialogFragment.newInstance(result ?: return)
|
||||
.show(childFragmentManager, BackupDialogFragment.TAG)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package org.koitharu.kotatsu.ui.settings.backup
|
||||
|
||||
import moxy.viewstate.strategy.alias.AddToEndSingle
|
||||
import moxy.viewstate.strategy.alias.SingleState
|
||||
import org.koitharu.kotatsu.ui.base.BaseMvpView
|
||||
import org.koitharu.kotatsu.utils.progress.Progress
|
||||
import java.io.File
|
||||
|
||||
interface BackupView : BaseMvpView {
|
||||
|
||||
@AddToEndSingle
|
||||
fun onProgressChanged(progress: Progress?)
|
||||
|
||||
@SingleState
|
||||
fun onBackupDone(file: File)
|
||||
}
|
||||
@ -0,0 +1,86 @@
|
||||
package org.koitharu.kotatsu.ui.settings.backup
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isVisible
|
||||
import kotlinx.android.synthetic.main.dialog_progress.*
|
||||
import moxy.ktx.moxyPresenter
|
||||
import org.koin.android.ext.android.get
|
||||
import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.backup.CompositeResult
|
||||
import org.koitharu.kotatsu.ui.base.AlertDialogFragment
|
||||
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
|
||||
import org.koitharu.kotatsu.utils.ext.toUriOrNull
|
||||
import org.koitharu.kotatsu.utils.ext.withArgs
|
||||
import org.koitharu.kotatsu.utils.progress.Progress
|
||||
|
||||
class RestoreDialogFragment : AlertDialogFragment(R.layout.dialog_progress), RestoreView {
|
||||
|
||||
@Suppress("unused")
|
||||
private val presenter by moxyPresenter {
|
||||
RestorePresenter(arguments?.getString(ARG_FILE)?.toUriOrNull(), get())
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
textView_title.setText(R.string.restore_backup)
|
||||
textView_subtitle.setText(R.string.preparing_)
|
||||
}
|
||||
|
||||
override fun onBuildDialog(builder: AlertDialog.Builder) {
|
||||
builder.setCancelable(false)
|
||||
}
|
||||
|
||||
override fun onError(e: Throwable) {
|
||||
AlertDialog.Builder(context ?: return)
|
||||
.setNegativeButton(R.string.close, null)
|
||||
.setTitle(R.string.error)
|
||||
.setMessage(e.getDisplayMessage(resources))
|
||||
.show()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
override fun onLoadingStateChanged(isLoading: Boolean) = Unit
|
||||
|
||||
override fun onProgressChanged(progress: Progress?) {
|
||||
with(progressBar) {
|
||||
isVisible = true
|
||||
isIndeterminate = progress == null
|
||||
if (progress != null) {
|
||||
this.max = progress.total
|
||||
this.progress = progress.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRestoreDone(result: CompositeResult) {
|
||||
val builder = AlertDialog.Builder(context ?: return)
|
||||
when {
|
||||
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()
|
||||
dismiss()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
const val ARG_FILE = "file"
|
||||
const val TAG = "RestoreDialogFragment"
|
||||
|
||||
fun newInstance(uri: Uri) = RestoreDialogFragment().withArgs(1) {
|
||||
putString(ARG_FILE, uri.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
package org.koitharu.kotatsu.ui.settings.backup
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.get
|
||||
import org.koitharu.kotatsu.core.backup.BackupArchive
|
||||
import org.koitharu.kotatsu.core.backup.BackupEntry
|
||||
import org.koitharu.kotatsu.core.backup.CompositeResult
|
||||
import org.koitharu.kotatsu.core.backup.RestoreRepository
|
||||
import org.koitharu.kotatsu.ui.base.BasePresenter
|
||||
import org.koitharu.kotatsu.utils.progress.Progress
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
class RestorePresenter(
|
||||
private val uri: Uri?,
|
||||
private val repository: RestoreRepository
|
||||
) : BasePresenter<RestoreView>() {
|
||||
|
||||
override fun onFirstViewAttach() {
|
||||
super.onFirstViewAttach()
|
||||
launchLoadingJob {
|
||||
viewState.onProgressChanged(null)
|
||||
if (uri == null) {
|
||||
throw FileNotFoundException()
|
||||
}
|
||||
val contentResolver = get<Context>().contentResolver
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
val backup = withContext(Dispatchers.IO) {
|
||||
val tempFile = File.createTempFile("backup_", ".tmp")
|
||||
(contentResolver.openInputStream(uri)
|
||||
?: throw FileNotFoundException()).use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
BackupArchive(tempFile)
|
||||
}
|
||||
try {
|
||||
backup.unpack()
|
||||
val result = CompositeResult()
|
||||
|
||||
viewState.onProgressChanged(Progress(0, 3))
|
||||
result += repository.upsertHistory(backup.getEntry(BackupEntry.HISTORY))
|
||||
|
||||
viewState.onProgressChanged(Progress(1, 3))
|
||||
result += repository.upsertCategories(backup.getEntry(BackupEntry.CATEGORIES))
|
||||
|
||||
viewState.onProgressChanged(Progress(2, 3))
|
||||
result += repository.upsertFavourites(backup.getEntry(BackupEntry.FAVOURITES))
|
||||
|
||||
viewState.onProgressChanged(Progress(3, 3))
|
||||
viewState.onRestoreDone(result)
|
||||
} finally {
|
||||
withContext(NonCancellable) {
|
||||
backup.cleanup()
|
||||
backup.file.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package org.koitharu.kotatsu.ui.settings.backup
|
||||
|
||||
import moxy.viewstate.strategy.alias.AddToEndSingle
|
||||
import moxy.viewstate.strategy.alias.SingleState
|
||||
import org.koitharu.kotatsu.core.backup.CompositeResult
|
||||
import org.koitharu.kotatsu.ui.base.BaseMvpView
|
||||
import org.koitharu.kotatsu.utils.progress.Progress
|
||||
|
||||
interface RestoreView : BaseMvpView {
|
||||
|
||||
@AddToEndSingle
|
||||
fun onProgressChanged(progress: Progress?)
|
||||
|
||||
@SingleState
|
||||
fun onRestoreDone(result: CompositeResult)
|
||||
}
|
||||
@ -0,0 +1,105 @@
|
||||
package org.koitharu.kotatsu.utils
|
||||
|
||||
import androidx.annotation.CheckResult
|
||||
import androidx.annotation.WorkerThread
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
open class MutableZipFile(val file: File) {
|
||||
|
||||
protected val dir = File(file.parentFile, file.nameWithoutExtension)
|
||||
|
||||
suspend fun unpack(): Unit = withContext(Dispatchers.IO) {
|
||||
check(dir.list().isNullOrEmpty()) {
|
||||
"Dir ${dir.name} is not empty"
|
||||
}
|
||||
if (!dir.exists()) {
|
||||
dir.mkdir()
|
||||
}
|
||||
if (!file.exists()) {
|
||||
return@withContext
|
||||
}
|
||||
ZipInputStream(FileInputStream(file)).use { zip ->
|
||||
var entry = zip.nextEntry
|
||||
while (entry != null) {
|
||||
val target = File(dir.path + File.separator + entry.name)
|
||||
target.parentFile?.mkdirs()
|
||||
target.outputStream().use { out ->
|
||||
zip.copyTo(out)
|
||||
}
|
||||
zip.closeEntry()
|
||||
entry = zip.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun cleanup() = withContext(Dispatchers.IO) {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
|
||||
@CheckResult
|
||||
suspend fun flush(): Boolean = withContext(Dispatchers.IO) {
|
||||
val tempFile = File(file.path + ".tmp")
|
||||
if (tempFile.exists()) {
|
||||
tempFile.delete()
|
||||
}
|
||||
try {
|
||||
ZipOutputStream(FileOutputStream(tempFile)).use { zip ->
|
||||
dir.listFiles()?.forEach {
|
||||
zipFile(it, it.name, zip)
|
||||
}
|
||||
zip.flush()
|
||||
}
|
||||
return@withContext tempFile.renameTo(file)
|
||||
} finally {
|
||||
if (tempFile.exists()) {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
operator fun get(name: String) = File(dir, name)
|
||||
|
||||
suspend fun put(name: String, file: File): Unit = withContext(Dispatchers.IO) {
|
||||
file.copyTo(this@MutableZipFile[name], overwrite = true)
|
||||
}
|
||||
|
||||
suspend fun put(name: String, data: String): Unit = withContext(Dispatchers.IO) {
|
||||
this@MutableZipFile[name].writeText(data)
|
||||
}
|
||||
|
||||
suspend fun getContent(name: String): String = withContext(Dispatchers.IO) {
|
||||
get(name).readText()
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@WorkerThread
|
||||
private fun zipFile(fileToZip: File, fileName: String, zipOut: ZipOutputStream) {
|
||||
if (fileToZip.isDirectory) {
|
||||
if (fileName.endsWith("/")) {
|
||||
zipOut.putNextEntry(ZipEntry(fileName))
|
||||
} else {
|
||||
zipOut.putNextEntry(ZipEntry("$fileName/"))
|
||||
}
|
||||
zipOut.closeEntry()
|
||||
fileToZip.listFiles()?.forEach { childFile ->
|
||||
zipFile(childFile, "$fileName/${childFile.name}", zipOut)
|
||||
}
|
||||
} else {
|
||||
FileInputStream(fileToZip).use { fis ->
|
||||
val zipEntry = ZipEntry(fileName)
|
||||
zipOut.putNextEntry(zipEntry)
|
||||
fis.copyTo(zipOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
package org.koitharu.kotatsu.utils.progress
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.android.parcel.Parcelize
|
||||
|
||||
@Parcelize
|
||||
data class Progress(
|
||||
val value: Int,
|
||||
val total: Int
|
||||
) : Parcelable, Comparable<Progress> {
|
||||
|
||||
override fun compareTo(other: Progress): Int {
|
||||
if (this.total == other.total) {
|
||||
return this.value.compareTo(other.value)
|
||||
} else {
|
||||
TODO()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?colorControlNormal"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M11,7h2v2h-2zM11,11h2v6h-2zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8z" />
|
||||
</vector>
|
||||
@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Medium"
|
||||
android:textColor="?android:textColorPrimary"
|
||||
tools:text="Title" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
style="?android:progressBarStyleHorizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/textView_subtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Small"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
tools:text="Subtitle" />
|
||||
|
||||
</LinearLayout>
|
||||
@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<PreferenceScreen
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<Preference
|
||||
android:key="backup"
|
||||
android:persistent="false"
|
||||
android:title="@string/create_backup"
|
||||
app:iconSpaceReserved="false" />
|
||||
|
||||
<Preference
|
||||
android:key="restore"
|
||||
android:persistent="false"
|
||||
android:title="@string/restore_backup"
|
||||
app:iconSpaceReserved="false" />
|
||||
|
||||
<Preference
|
||||
android:icon="@drawable/ic_info_outilne"
|
||||
android:persistent="false"
|
||||
android:selectable="false"
|
||||
android:summary="@string/backup_information"
|
||||
app:allowDividerAbove="true" />
|
||||
|
||||
</PreferenceScreen>
|
||||
@ -1,6 +1,6 @@
|
||||
#Wed Aug 26 15:09:14 EEST 2020
|
||||
#Sun Nov 15 14:08:38 EET 2020
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.6.1-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip
|
||||
|
||||
Loading…
Reference in New Issue