Backup and restore user data
This commit is contained in:
@@ -7,6 +7,8 @@ import org.koin.android.ext.android.get
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.dsl.module
|
||||
import org.koitharu.kotatsu.core.backup.BackupRepository
|
||||
import org.koitharu.kotatsu.core.backup.RestoreRepository
|
||||
import org.koitharu.kotatsu.core.db.databaseModule
|
||||
import org.koitharu.kotatsu.core.github.githubModule
|
||||
import org.koitharu.kotatsu.core.local.PagesCache
|
||||
@@ -67,6 +69,8 @@ class KotatsuApp : Application() {
|
||||
single { HistoryRepository(get()) }
|
||||
single { TrackingRepository(get(), get()) }
|
||||
single { MangaDataRepository(get()) }
|
||||
single { BackupRepository(get()) }
|
||||
single { RestoreRepository(get()) }
|
||||
single { MangaSearchRepository() }
|
||||
single { MangaLoaderContext() }
|
||||
single { AppSettings(get()) }
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
package org.koitharu.kotatsu.core.db.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.*
|
||||
import org.koitharu.kotatsu.core.db.entity.FavouriteCategoryEntity
|
||||
|
||||
@Dao
|
||||
@@ -15,6 +12,9 @@ abstract class FavouriteCategoriesDao {
|
||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||
abstract suspend fun insert(category: FavouriteCategoryEntity): Long
|
||||
|
||||
@Update
|
||||
abstract suspend fun update(category: FavouriteCategoryEntity): Int
|
||||
|
||||
@Query("DELETE FROM favourite_categories WHERE category_id = :id")
|
||||
abstract suspend fun delete(id: Long)
|
||||
|
||||
@@ -30,4 +30,11 @@ abstract class FavouriteCategoriesDao {
|
||||
suspend fun getNextSortKey(): Int {
|
||||
return (getMaxSortKey() ?: 0) + 1
|
||||
}
|
||||
|
||||
@Transaction
|
||||
open suspend fun upsert(entity: FavouriteCategoryEntity) {
|
||||
if (update(entity) == 0) {
|
||||
insert(entity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,18 @@ abstract class FavouritesDao {
|
||||
abstract suspend fun find(id: Long): FavouriteManga?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||
abstract suspend fun add(favourite: FavouriteEntity)
|
||||
abstract suspend fun insert(favourite: FavouriteEntity)
|
||||
|
||||
@Update
|
||||
abstract suspend fun update(favourite: FavouriteEntity): Int
|
||||
|
||||
@Query("DELETE FROM favourites WHERE manga_id = :mangaId AND category_id = :categoryId")
|
||||
abstract suspend fun delete(categoryId: Long, mangaId: Long)
|
||||
|
||||
@Transaction
|
||||
open suspend fun upsert(entity: FavouriteEntity) {
|
||||
if (update(entity) == 0) {
|
||||
insert(entity)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,5 +154,7 @@ class AppSettings private constructor(private val prefs: SharedPreferences) :
|
||||
const val KEY_PROTECT_APP = "protect_app"
|
||||
const val KEY_APP_VERSION = "app_version"
|
||||
const val KEY_ZOOM_MODE = "zoom_mode"
|
||||
const val KEY_BACKUP = "backup"
|
||||
const val KEY_RESTORE = "restore"
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ class FavouritesRepository(private val db: MangaDatabase) {
|
||||
db.tagsDao.upsert(tags)
|
||||
db.mangaDao.upsert(MangaEntity.from(manga), tags)
|
||||
val entity = FavouriteEntity(manga.id, categoryId, System.currentTimeMillis())
|
||||
db.favouritesDao.add(entity)
|
||||
db.favouritesDao.insert(entity)
|
||||
}
|
||||
notifyFavouritesChanged(manga.id)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.view.View
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import moxy.MvpAppCompatDialogFragment
|
||||
|
||||
abstract class AlertDialogFragment(
|
||||
@@ -21,7 +20,7 @@ abstract class AlertDialogFragment(
|
||||
if (view != null) {
|
||||
onViewCreated(view, savedInstanceState)
|
||||
}
|
||||
return MaterialAlertDialogBuilder(requireContext(), theme)
|
||||
return AlertDialog.Builder(requireContext(), theme)
|
||||
.setView(view)
|
||||
.also(::onBuildDialog)
|
||||
.create()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
105
app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt
Normal file
105
app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,16 @@ object ShareHelper {
|
||||
context.startActivity(shareIntent)
|
||||
}
|
||||
|
||||
fun shareBackup(context: Context, file: File) {
|
||||
val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", file)
|
||||
val intent = Intent(Intent.ACTION_SEND)
|
||||
intent.setDataAndType(uri, context.contentResolver.getType(uri))
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
val shareIntent =
|
||||
Intent.createChooser(intent, context.getString(R.string.share_s, file.name))
|
||||
context.startActivity(shareIntent)
|
||||
}
|
||||
|
||||
fun shareImage(context: Context, uri: Uri) {
|
||||
val intent = Intent(Intent.ACTION_SEND)
|
||||
intent.setDataAndType(uri, context.contentResolver.getType(uri))
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.koitharu.kotatsu.R
|
||||
import org.koitharu.kotatsu.core.exceptions.EmptyHistoryException
|
||||
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
|
||||
import org.koitharu.kotatsu.core.exceptions.WrongPasswordException
|
||||
import java.io.FileNotFoundException
|
||||
import java.net.SocketTimeoutException
|
||||
|
||||
inline fun <T, R> T.safe(action: T.() -> R?) = try {
|
||||
@@ -38,6 +39,7 @@ suspend inline fun <T, R> T.retryUntilSuccess(maxAttempts: Int, action: T.() ->
|
||||
fun Throwable.getDisplayMessage(resources: Resources) = when (this) {
|
||||
is UnsupportedOperationException -> resources.getString(R.string.operation_not_supported)
|
||||
is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported)
|
||||
is FileNotFoundException -> resources.getString(R.string.file_not_found)
|
||||
is EmptyHistoryException -> resources.getString(R.string.history_is_empty)
|
||||
is SocketTimeoutException -> resources.getString(R.string.network_error)
|
||||
is WrongPasswordException -> resources.getString(R.string.wrong_password)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
11
app/src/main/res/drawable/ic_info_outilne.xml
Normal file
11
app/src/main/res/drawable/ic_info_outilne.xml
Normal file
@@ -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>
|
||||
35
app/src/main/res/layout/dialog_progress.xml
Normal file
35
app/src/main/res/layout/dialog_progress.xml
Normal file
@@ -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>
|
||||
@@ -171,4 +171,13 @@
|
||||
<string name="black_dark_theme">Чёрная тёмная тема</string>
|
||||
<string name="black_dark_theme_summary">Полезно для AMOLED экранов</string>
|
||||
<string name="restart_required">Требуется перезапуск</string>
|
||||
<string name="backup_restore">Резервное копирование</string>
|
||||
<string name="create_backup">Создать резервную копию</string>
|
||||
<string name="restore_backup">Восстановить данные</string>
|
||||
<string name="data_restored">Данные восстановлены</string>
|
||||
<string name="preparing_">Подготовка…</string>
|
||||
<string name="file_not_found">Файл не найден</string>
|
||||
<string name="data_restored_success">Все данные успешно восстановлены</string>
|
||||
<string name="data_restored_with_errors">Данные восстановлены, но возникли некоторые ошибки</string>
|
||||
<string name="backup_information">You can create backup of your history and favourites and restore it</string>
|
||||
</resources>
|
||||
@@ -173,4 +173,13 @@
|
||||
<string name="black_dark_theme">Black dark theme</string>
|
||||
<string name="black_dark_theme_summary">Useful for AMOLED screens</string>
|
||||
<string name="restart_required">Restart required</string>
|
||||
<string name="backup_restore"><![CDATA[Backup & Restore]]></string>
|
||||
<string name="create_backup">Create data backup</string>
|
||||
<string name="restore_backup">Restore from backup</string>
|
||||
<string name="data_restored">Data restored</string>
|
||||
<string name="preparing_">Preparing…</string>
|
||||
<string name="file_not_found">File not found</string>
|
||||
<string name="data_restored_success">All data restored successfully</string>
|
||||
<string name="data_restored_with_errors">The data restored, but there are errors</string>
|
||||
<string name="backup_information">You can create backup of your history and favourites and restore it</string>
|
||||
</resources>
|
||||
@@ -3,4 +3,7 @@
|
||||
<external-files-path
|
||||
name="manga"
|
||||
path="/manga" />
|
||||
<external-files-path
|
||||
name="backups"
|
||||
path="/backups" />
|
||||
</paths>
|
||||
25
app/src/main/res/xml/pref_backup.xml
Normal file
25
app/src/main/res/xml/pref_backup.xml
Normal file
@@ -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>
|
||||
@@ -16,9 +16,9 @@
|
||||
<SwitchPreference
|
||||
android:defaultValue="false"
|
||||
android:key="amoled_theme"
|
||||
app:iconSpaceReserved="false"
|
||||
android:summary="@string/black_dark_theme_summary"
|
||||
android:title="@string/black_dark_theme"
|
||||
android:summary="@string/black_dark_theme_summary" />
|
||||
app:iconSpaceReserved="false" />
|
||||
|
||||
<Preference
|
||||
android:key="list_mode"
|
||||
@@ -81,8 +81,8 @@
|
||||
android:entries="@array/zoom_modes"
|
||||
android:key="zoom_mode"
|
||||
android:title="@string/scale_mode"
|
||||
app:useSimpleSummaryProvider="true"
|
||||
app:iconSpaceReserved="false" />
|
||||
app:iconSpaceReserved="false"
|
||||
app:useSimpleSummaryProvider="true" />
|
||||
|
||||
<SwitchPreference
|
||||
android:defaultValue="false"
|
||||
@@ -123,6 +123,11 @@
|
||||
android:title="@string/about"
|
||||
app:iconSpaceReserved="false">
|
||||
|
||||
<PreferenceScreen
|
||||
android:fragment="org.koitharu.kotatsu.ui.settings.backup.BackupSettingsFragment"
|
||||
android:title="@string/backup_restore"
|
||||
app:iconSpaceReserved="false" />
|
||||
|
||||
<SwitchPreference
|
||||
android:defaultValue="true"
|
||||
android:key="app_update_auto"
|
||||
|
||||
Reference in New Issue
Block a user