Backup and restore user data

This commit is contained in:
Koitharu
2020-11-16 19:15:43 +02:00
parent 03dbd86363
commit d135898b49
32 changed files with 955 additions and 17 deletions

View File

@@ -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()) }

View File

@@ -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))
}
}
}

View File

@@ -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"
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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")
)
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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"
}
}

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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"
}
}

View File

@@ -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)
}
}
}

View 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)
}
}

View File

@@ -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)
}

View 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())
}
}
}

View File

@@ -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()
}
}
}
}
}

View File

@@ -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)
}

View 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)
}
}
}
}
}

View File

@@ -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))

View File

@@ -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)

View File

@@ -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()
}
}
}

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -3,4 +3,7 @@
<external-files-path
name="manga"
path="/manga" />
<external-files-path
name="backups"
path="/backups" />
</paths>

View 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>

View File

@@ -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"