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
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
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