From d135898b49628f2fdc294c32f348dd4062f93c78 Mon Sep 17 00:00:00 2001 From: Koitharu Date: Mon, 16 Nov 2020 19:15:43 +0200 Subject: [PATCH] Backup and restore user data --- app/build.gradle | 4 +- .../java/org/koitharu/kotatsu/KotatsuApp.kt | 4 + .../kotatsu/core/backup/BackupArchive.kt | 51 +++++++ .../kotatsu/core/backup/BackupEntry.kt | 17 +++ .../kotatsu/core/backup/BackupRepository.kt | 130 ++++++++++++++++++ .../kotatsu/core/backup/CompositeResult.kt | 39 ++++++ .../kotatsu/core/backup/RestoreRepository.kt | 105 ++++++++++++++ .../core/db/dao/FavouriteCategoriesDao.kt | 15 +- .../kotatsu/core/db/dao/FavouritesDao.kt | 12 +- .../kotatsu/core/prefs/AppSettings.kt | 2 + .../domain/favourites/FavouritesRepository.kt | 2 +- .../kotatsu/ui/base/AlertDialogFragment.kt | 3 +- .../settings/backup/BackupDialogFragment.kt | 66 +++++++++ .../ui/settings/backup/BackupPresenter.kt | 36 +++++ .../settings/backup/BackupSettingsFragment.kt | 55 ++++++++ .../kotatsu/ui/settings/backup/BackupView.kt | 16 +++ .../settings/backup/RestoreDialogFragment.kt | 86 ++++++++++++ .../ui/settings/backup/RestorePresenter.kt | 66 +++++++++ .../kotatsu/ui/settings/backup/RestoreView.kt | 16 +++ .../koitharu/kotatsu/utils/MutableZipFile.kt | 105 ++++++++++++++ .../org/koitharu/kotatsu/utils/ShareHelper.kt | 10 ++ .../koitharu/kotatsu/utils/ext/CommonExt.kt | 2 + .../kotatsu/utils/progress/Progress.kt | 19 +++ app/src/main/res/drawable/ic_info_outilne.xml | 11 ++ app/src/main/res/layout/dialog_progress.xml | 35 +++++ app/src/main/res/values-ru/strings.xml | 9 ++ app/src/main/res/values/strings.xml | 9 ++ app/src/main/res/xml/filepaths.xml | 3 + app/src/main/res/xml/pref_backup.xml | 25 ++++ app/src/main/res/xml/pref_main.xml | 13 +- build.gradle | 2 +- gradle/wrapper/gradle-wrapper.properties | 4 +- 32 files changed, 955 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/backup/BackupArchive.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/backup/BackupEntry.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/backup/CompositeResult.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/BackupDialogFragment.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/BackupPresenter.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/BackupSettingsFragment.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/BackupView.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/RestoreDialogFragment.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/RestorePresenter.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/RestoreView.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt create mode 100644 app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt create mode 100644 app/src/main/res/drawable/ic_info_outilne.xml create mode 100644 app/src/main/res/layout/dialog_progress.xml create mode 100644 app/src/main/res/xml/pref_backup.xml diff --git a/app/build.gradle b/app/build.gradle index 2199ca611..fa799eefe 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -66,7 +66,7 @@ dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.1' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.1' - implementation 'androidx.core:core-ktx:1.5.0-alpha04' + implementation 'androidx.core:core-ktx:1.5.0-alpha05' implementation 'androidx.activity:activity-ktx:1.2.0-beta01' implementation 'androidx.fragment:fragment-ktx:1.3.0-beta01' implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.0-beta01' @@ -94,7 +94,7 @@ dependencies { implementation 'com.squareup.okio:okio:2.9.0' implementation 'org.jsoup:jsoup:1.13.1' - implementation 'org.koin:koin-android:2.2.0-rc-2' + implementation 'org.koin:koin-android:2.2.0' implementation 'io.coil-kt:coil-base:1.0.0' implementation 'com.davemorrissey.labs:subsampling-scale-image-view:3.10.0' implementation 'com.tomclaw.cache:cache:1.0' diff --git a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt index 1e7ae550d..e8a4f29b4 100644 --- a/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt +++ b/app/src/main/java/org/koitharu/kotatsu/KotatsuApp.kt @@ -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()) } diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupArchive.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupArchive.kt new file mode 100644 index 000000000..c0af8d788 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupArchive.kt @@ -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)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupEntry.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupEntry.kt new file mode 100644 index 000000000..5de7d7e01 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupEntry.kt @@ -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" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt new file mode 100644 index 000000000..c8f40b3bb --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/BackupRepository.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/CompositeResult.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/CompositeResult.kt new file mode 100644 index 000000000..cd12a97fe --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/CompositeResult.kt @@ -0,0 +1,39 @@ +package org.koitharu.kotatsu.core.backup + +class CompositeResult { + + private var successCount: Int = 0 + private val errors = ArrayList() + + val size: Int + get() = successCount + errors.size + + val failures: List + 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 + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt b/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt new file mode 100644 index 000000000..a8afc1b6e --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/core/backup/RestoreRepository.kt @@ -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") + ) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/dao/FavouriteCategoriesDao.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/FavouriteCategoriesDao.kt index aad1cd592..4392261b1 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/dao/FavouriteCategoriesDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/FavouriteCategoriesDao.kt @@ -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) + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/db/dao/FavouritesDao.kt b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/FavouritesDao.kt index 552f1ca74..1ad93fbdb 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/db/dao/FavouritesDao.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/db/dao/FavouritesDao.kt @@ -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) + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt index e45cbd7ff..52409ace7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/java/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -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" } } \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/domain/favourites/FavouritesRepository.kt b/app/src/main/java/org/koitharu/kotatsu/domain/favourites/FavouritesRepository.kt index 6728c0741..8d3bf12b5 100644 --- a/app/src/main/java/org/koitharu/kotatsu/domain/favourites/FavouritesRepository.kt +++ b/app/src/main/java/org/koitharu/kotatsu/domain/favourites/FavouritesRepository.kt @@ -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) } diff --git a/app/src/main/java/org/koitharu/kotatsu/ui/base/AlertDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/ui/base/AlertDialogFragment.kt index cdc3633a6..128800038 100644 --- a/app/src/main/java/org/koitharu/kotatsu/ui/base/AlertDialogFragment.kt +++ b/app/src/main/java/org/koitharu/kotatsu/ui/base/AlertDialogFragment.kt @@ -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() diff --git a/app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/BackupDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/BackupDialogFragment.kt new file mode 100644 index 000000000..9ee572019 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/BackupDialogFragment.kt @@ -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" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/BackupPresenter.kt b/app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/BackupPresenter.kt new file mode 100644 index 000000000..9f2854c38 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/BackupPresenter.kt @@ -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() { + + 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/BackupSettingsFragment.kt b/app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/BackupSettingsFragment.kt new file mode 100644 index 000000000..02ebc35eb --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/BackupSettingsFragment.kt @@ -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 { + + 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) + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/BackupView.kt b/app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/BackupView.kt new file mode 100644 index 000000000..180eef1ff --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/BackupView.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/RestoreDialogFragment.kt b/app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/RestoreDialogFragment.kt new file mode 100644 index 000000000..f31037539 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/RestoreDialogFragment.kt @@ -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()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/RestorePresenter.kt b/app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/RestorePresenter.kt new file mode 100644 index 000000000..3c9ce9d37 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/RestorePresenter.kt @@ -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() { + + override fun onFirstViewAttach() { + super.onFirstViewAttach() + launchLoadingJob { + viewState.onProgressChanged(null) + if (uri == null) { + throw FileNotFoundException() + } + val contentResolver = get().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() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/RestoreView.kt b/app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/RestoreView.kt new file mode 100644 index 000000000..e81c411de --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/ui/settings/backup/RestoreView.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt b/app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt new file mode 100644 index 000000000..a57bae29c --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/MutableZipFile.kt @@ -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) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt index e33863949..47109dfc0 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ShareHelper.kt @@ -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)) diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt index 1a6fdba10..9aca6fbd7 100644 --- a/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt +++ b/app/src/main/java/org/koitharu/kotatsu/utils/ext/CommonExt.kt @@ -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.safe(action: T.() -> R?) = try { @@ -38,6 +39,7 @@ suspend inline fun 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) diff --git a/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt b/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt new file mode 100644 index 000000000..fadec62c5 --- /dev/null +++ b/app/src/main/java/org/koitharu/kotatsu/utils/progress/Progress.kt @@ -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 { + + override fun compareTo(other: Progress): Int { + if (this.total == other.total) { + return this.value.compareTo(other.value) + } else { + TODO() + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_info_outilne.xml b/app/src/main/res/drawable/ic_info_outilne.xml new file mode 100644 index 000000000..95cf5d02a --- /dev/null +++ b/app/src/main/res/drawable/ic_info_outilne.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/layout/dialog_progress.xml b/app/src/main/res/layout/dialog_progress.xml new file mode 100644 index 000000000..ad0935059 --- /dev/null +++ b/app/src/main/res/layout/dialog_progress.xml @@ -0,0 +1,35 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 59e2471ed..62b4495e8 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -171,4 +171,13 @@ Чёрная тёмная тема Полезно для AMOLED экранов Требуется перезапуск + Резервное копирование + Создать резервную копию + Восстановить данные + Данные восстановлены + Подготовка… + Файл не найден + Все данные успешно восстановлены + Данные восстановлены, но возникли некоторые ошибки + You can create backup of your history and favourites and restore it \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ba7b9331d..f4b5c380b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -173,4 +173,13 @@ Black dark theme Useful for AMOLED screens Restart required + + Create data backup + Restore from backup + Data restored + Preparing… + File not found + All data restored successfully + The data restored, but there are errors + You can create backup of your history and favourites and restore it \ No newline at end of file diff --git a/app/src/main/res/xml/filepaths.xml b/app/src/main/res/xml/filepaths.xml index b5c9b9e1a..8d2bc3f69 100644 --- a/app/src/main/res/xml/filepaths.xml +++ b/app/src/main/res/xml/filepaths.xml @@ -3,4 +3,7 @@ + \ No newline at end of file diff --git a/app/src/main/res/xml/pref_backup.xml b/app/src/main/res/xml/pref_backup.xml new file mode 100644 index 000000000..417e43b2c --- /dev/null +++ b/app/src/main/res/xml/pref_backup.xml @@ -0,0 +1,25 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/pref_main.xml b/app/src/main/res/xml/pref_main.xml index f0149d422..d48afcd59 100644 --- a/app/src/main/res/xml/pref_main.xml +++ b/app/src/main/res/xml/pref_main.xml @@ -16,9 +16,9 @@ + app:iconSpaceReserved="false" /> + app:iconSpaceReserved="false" + app:useSimpleSummaryProvider="true" /> + +