Use streams for backups
parent
4ef6908e82
commit
b9d4c070eb
@ -0,0 +1,250 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data
|
||||||
|
|
||||||
|
import androidx.collection.ArrayMap
|
||||||
|
import androidx.room.withTransaction
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.FlowCollector
|
||||||
|
import kotlinx.coroutines.flow.asFlow
|
||||||
|
import kotlinx.coroutines.flow.collectIndexed
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onCompletion
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
|
import kotlinx.serialization.DeserializationStrategy
|
||||||
|
import kotlinx.serialization.SerializationStrategy
|
||||||
|
import kotlinx.serialization.json.DecodeSequenceMode
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.decodeToSequence
|
||||||
|
import kotlinx.serialization.json.encodeToStream
|
||||||
|
import kotlinx.serialization.serializer
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.BackupIndex
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.BookmarkBackup
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.CategoryBackup
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.FavouriteBackup
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.HistoryBackup
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.MangaBackup
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.SourceBackup
|
||||||
|
import org.koitharu.kotatsu.backups.domain.BackupSection
|
||||||
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.core.util.CompositeResult
|
||||||
|
import org.koitharu.kotatsu.core.util.progress.Progress
|
||||||
|
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
||||||
|
import org.koitharu.kotatsu.reader.data.TapGridSettings
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class BackupRepository @Inject constructor(
|
||||||
|
private val database: MangaDatabase,
|
||||||
|
private val settings: AppSettings,
|
||||||
|
private val tapGridSettings: TapGridSettings,
|
||||||
|
) {
|
||||||
|
|
||||||
|
suspend fun createBackup(
|
||||||
|
output: ZipOutputStream,
|
||||||
|
progress: FlowCollector<Progress>?
|
||||||
|
) {
|
||||||
|
progress?.emit(Progress.INDETERMINATE)
|
||||||
|
var commonProgress = Progress(0, BackupSection.entries.size)
|
||||||
|
for (section in BackupSection.entries) {
|
||||||
|
when (section) {
|
||||||
|
BackupSection.INDEX -> output.writeJsonArray(
|
||||||
|
section = BackupSection.INDEX,
|
||||||
|
data = flowOf(BackupIndex()),
|
||||||
|
serializer = serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupSection.HISTORY -> output.writeJsonArray(
|
||||||
|
section = BackupSection.HISTORY,
|
||||||
|
data = database.getHistoryDao().dump().map { HistoryBackup(it) },
|
||||||
|
serializer = serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupSection.CATEGORIES -> output.writeJsonArray(
|
||||||
|
section = BackupSection.CATEGORIES,
|
||||||
|
data = database.getFavouriteCategoriesDao().findAll().asFlow().map { CategoryBackup(it) },
|
||||||
|
serializer = serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupSection.FAVOURITES -> output.writeJsonArray(
|
||||||
|
section = BackupSection.FAVOURITES,
|
||||||
|
data = database.getFavouritesDao().dump().map { FavouriteBackup(it) },
|
||||||
|
serializer = serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupSection.SETTINGS -> output.writeString(
|
||||||
|
section = BackupSection.SETTINGS,
|
||||||
|
data = dumpSettings(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupSection.SETTINGS_READER_GRID -> output.writeString(
|
||||||
|
section = BackupSection.SETTINGS_READER_GRID,
|
||||||
|
data = dumpReaderGridSettings(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupSection.BOOKMARKS -> output.writeJsonArray(
|
||||||
|
section = BackupSection.BOOKMARKS,
|
||||||
|
data = database.getBookmarksDao().dump().map { BookmarkBackup(it.first, it.second) },
|
||||||
|
serializer = serializer(),
|
||||||
|
)
|
||||||
|
|
||||||
|
BackupSection.SOURCES -> output.writeJsonArray(
|
||||||
|
section = BackupSection.SOURCES,
|
||||||
|
data = database.getSourcesDao().dumpEnabled().map { SourceBackup(it) },
|
||||||
|
serializer = serializer(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
progress?.emit(commonProgress)
|
||||||
|
commonProgress++
|
||||||
|
}
|
||||||
|
progress?.emit(commonProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun restoreBackup(
|
||||||
|
input: ZipInputStream,
|
||||||
|
sections: Set<BackupSection>,
|
||||||
|
progress: FlowCollector<Progress>?
|
||||||
|
): CompositeResult {
|
||||||
|
progress?.emit(Progress.INDETERMINATE)
|
||||||
|
var commonProgress = Progress(0, sections.size)
|
||||||
|
var entry = input.nextEntry
|
||||||
|
var result = CompositeResult.EMPTY
|
||||||
|
while (entry != null) {
|
||||||
|
val section = BackupSection.of(entry)
|
||||||
|
if (section in sections) {
|
||||||
|
result = result + when (section) {
|
||||||
|
BackupSection.INDEX -> CompositeResult.EMPTY // useless in our case
|
||||||
|
BackupSection.HISTORY -> input.readJsonArray<HistoryBackup>(serializer()).restoreToDb {
|
||||||
|
upsertManga(it.manga)
|
||||||
|
getHistoryDao().upsert(it.toEntity())
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.CATEGORIES -> input.readJsonArray<CategoryBackup>(serializer()).restoreToDb {
|
||||||
|
getFavouriteCategoriesDao().upsert(it.toEntity())
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.FAVOURITES -> input.readJsonArray<FavouriteBackup>(serializer()).restoreToDb {
|
||||||
|
upsertManga(it.manga)
|
||||||
|
getFavouritesDao().upsert(it.toEntity())
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.SETTINGS -> input.readMap().let {
|
||||||
|
settings.upsertAll(it)
|
||||||
|
CompositeResult.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.SETTINGS_READER_GRID -> input.readMap().let {
|
||||||
|
tapGridSettings.upsertAll(it)
|
||||||
|
CompositeResult.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.BOOKMARKS -> input.readJsonArray<BookmarkBackup>(serializer()).restoreToDb {
|
||||||
|
upsertManga(it.manga)
|
||||||
|
getBookmarksDao().upsert(it.bookmarks.map { b -> b.toEntity() })
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupSection.SOURCES -> input.readJsonArray<SourceBackup>(serializer()).restoreToDb {
|
||||||
|
getSourcesDao().upsert(it.toEntity())
|
||||||
|
}
|
||||||
|
|
||||||
|
null -> CompositeResult.EMPTY // skip unknown entries
|
||||||
|
}
|
||||||
|
progress?.emit(commonProgress)
|
||||||
|
commonProgress++
|
||||||
|
}
|
||||||
|
input.closeEntry()
|
||||||
|
entry = input.nextEntry
|
||||||
|
}
|
||||||
|
progress?.emit(commonProgress)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> ZipOutputStream.writeJsonArray(
|
||||||
|
section: BackupSection,
|
||||||
|
data: Flow<T>,
|
||||||
|
serializer: SerializationStrategy<T>,
|
||||||
|
) {
|
||||||
|
data.onStart {
|
||||||
|
putNextEntry(ZipEntry(section.entryName))
|
||||||
|
write("[")
|
||||||
|
}.onCompletion {
|
||||||
|
write("]")
|
||||||
|
closeEntry()
|
||||||
|
flush()
|
||||||
|
}.collectIndexed { index, value ->
|
||||||
|
if (index > 0) {
|
||||||
|
write(",")
|
||||||
|
}
|
||||||
|
Json.encodeToStream(serializer, value, this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> InputStream.readJsonArray(
|
||||||
|
serializer: DeserializationStrategy<T>,
|
||||||
|
): Sequence<T> = Json.decodeToSequence(this, serializer, DecodeSequenceMode.ARRAY_WRAPPED)
|
||||||
|
|
||||||
|
private fun InputStream.readMap(): Map<String, Any?> {
|
||||||
|
val jo = JSONArray(readString()).getJSONObject(0)
|
||||||
|
val map = ArrayMap<String, Any?>(jo.length())
|
||||||
|
val keys = jo.keys()
|
||||||
|
while (keys.hasNext()) {
|
||||||
|
val key = keys.next()
|
||||||
|
map[key] = jo.get(key)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ZipOutputStream.writeString(
|
||||||
|
section: BackupSection,
|
||||||
|
data: String,
|
||||||
|
) {
|
||||||
|
putNextEntry(ZipEntry(section.entryName))
|
||||||
|
try {
|
||||||
|
write("[")
|
||||||
|
write(data)
|
||||||
|
write("]")
|
||||||
|
} finally {
|
||||||
|
closeEntry()
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun OutputStream.write(str: String) = write(str.toByteArray())
|
||||||
|
|
||||||
|
private fun InputStream.readString(): String = readBytes().decodeToString()
|
||||||
|
|
||||||
|
private fun dumpSettings(): String {
|
||||||
|
val map = settings.getAllValues().toMutableMap()
|
||||||
|
map.remove(AppSettings.KEY_APP_PASSWORD)
|
||||||
|
map.remove(AppSettings.KEY_PROXY_PASSWORD)
|
||||||
|
map.remove(AppSettings.KEY_PROXY_LOGIN)
|
||||||
|
map.remove(AppSettings.KEY_INCOGNITO_MODE)
|
||||||
|
return JSONObject(map).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dumpReaderGridSettings(): String {
|
||||||
|
return JSONObject(tapGridSettings.getAllValues()).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun MangaDatabase.upsertManga(manga: MangaBackup) {
|
||||||
|
val tags = manga.tags.map { it.toEntity() }
|
||||||
|
getTagsDao().upsert(tags)
|
||||||
|
getMangaDao().upsert(manga.toEntity(), tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend inline fun <T> Sequence<T>.restoreToDb(crossinline block: suspend MangaDatabase.(T) -> Unit): CompositeResult {
|
||||||
|
return fold(CompositeResult.EMPTY) { result, item ->
|
||||||
|
result + runCatchingCancellable {
|
||||||
|
database.withTransaction {
|
||||||
|
database.block(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.BuildConfig
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class BackupIndex(
|
||||||
|
@SerialName("app_id") val appId: String,
|
||||||
|
@SerialName("app_version") val appVersion: Int,
|
||||||
|
@SerialName("created_at") val createdAt: Long,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor() : this(
|
||||||
|
appId = BuildConfig.APPLICATION_ID,
|
||||||
|
appVersion = BuildConfig.VERSION_CODE,
|
||||||
|
createdAt = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class BookmarkBackup(
|
||||||
|
@SerialName("manga") val manga: MangaBackup,
|
||||||
|
@SerialName("tags") val tags: Set<TagBackup>,
|
||||||
|
@SerialName("bookmarks") val bookmarks: List<Bookmark>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class Bookmark(
|
||||||
|
@SerialName("manga_id") val mangaId: Long,
|
||||||
|
@SerialName("page_id") val pageId: Long,
|
||||||
|
@SerialName("chapter_id") val chapterId: Long,
|
||||||
|
@SerialName("page") val page: Int,
|
||||||
|
@SerialName("scroll") val scroll: Int,
|
||||||
|
@SerialName("image") val imageUrl: String,
|
||||||
|
@SerialName("created_at") val createdAt: Long,
|
||||||
|
@SerialName("percent") val percent: Float,
|
||||||
|
) {
|
||||||
|
|
||||||
|
fun toEntity() = BookmarkEntity(
|
||||||
|
mangaId = mangaId,
|
||||||
|
pageId = pageId,
|
||||||
|
chapterId = chapterId,
|
||||||
|
page = page,
|
||||||
|
scroll = scroll,
|
||||||
|
imageUrl = imageUrl,
|
||||||
|
createdAt = createdAt,
|
||||||
|
percent = percent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(manga: MangaWithTags, entities: List<BookmarkEntity>) : this(
|
||||||
|
manga = MangaBackup(manga.copy(tags = emptyList())),
|
||||||
|
tags = manga.tags.mapToSet { TagBackup(it) },
|
||||||
|
bookmarks = entities.map {
|
||||||
|
Bookmark(
|
||||||
|
mangaId = it.mangaId,
|
||||||
|
pageId = it.pageId,
|
||||||
|
chapterId = it.chapterId,
|
||||||
|
page = it.page,
|
||||||
|
scroll = it.scroll,
|
||||||
|
imageUrl = it.imageUrl,
|
||||||
|
createdAt = it.createdAt,
|
||||||
|
percent = it.percent,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
||||||
|
import org.koitharu.kotatsu.list.domain.ListSortOrder
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class CategoryBackup(
|
||||||
|
@SerialName("category_id") val categoryId: Int,
|
||||||
|
@SerialName("created_at") val createdAt: Long,
|
||||||
|
@SerialName("sort_key") val sortKey: Int,
|
||||||
|
@SerialName("title") val title: String,
|
||||||
|
@SerialName("order") val order: String = ListSortOrder.NEWEST.name,
|
||||||
|
@SerialName("track") val track: Boolean = true,
|
||||||
|
@SerialName("show_in_lib") val isVisibleInLibrary: Boolean = true,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: FavouriteCategoryEntity) : this(
|
||||||
|
categoryId = entity.categoryId,
|
||||||
|
createdAt = entity.createdAt,
|
||||||
|
sortKey = entity.sortKey,
|
||||||
|
title = entity.title,
|
||||||
|
order = entity.order,
|
||||||
|
track = entity.track,
|
||||||
|
isVisibleInLibrary = entity.isVisibleInLibrary,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = FavouriteCategoryEntity(
|
||||||
|
categoryId = categoryId,
|
||||||
|
createdAt = createdAt,
|
||||||
|
sortKey = sortKey,
|
||||||
|
title = title,
|
||||||
|
order = order,
|
||||||
|
track = track,
|
||||||
|
isVisibleInLibrary = isVisibleInLibrary,
|
||||||
|
deletedAt = 0L,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||||
|
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
||||||
|
import org.koitharu.kotatsu.favourites.data.FavouriteManga
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class FavouriteBackup(
|
||||||
|
@SerialName("manga_id") val mangaId: Long,
|
||||||
|
@SerialName("category_id") val categoryId: Long,
|
||||||
|
@SerialName("sort_key") val sortKey: Int = 0,
|
||||||
|
@SerialName("pinned") val isPinned: Boolean = false,
|
||||||
|
@SerialName("created_at") val createdAt: Long,
|
||||||
|
@SerialName("manga") val manga: MangaBackup,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: FavouriteManga) : this(
|
||||||
|
mangaId = entity.manga.id,
|
||||||
|
categoryId = entity.favourite.categoryId,
|
||||||
|
sortKey = entity.favourite.sortKey,
|
||||||
|
isPinned = entity.favourite.isPinned,
|
||||||
|
createdAt = entity.favourite.createdAt,
|
||||||
|
manga = MangaBackup(MangaWithTags(entity.manga, entity.tags)),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = FavouriteEntity(
|
||||||
|
mangaId = mangaId,
|
||||||
|
categoryId = categoryId,
|
||||||
|
sortKey = sortKey,
|
||||||
|
isPinned = isPinned,
|
||||||
|
createdAt = createdAt,
|
||||||
|
deletedAt = 0L,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||||
|
import org.koitharu.kotatsu.history.data.HistoryEntity
|
||||||
|
import org.koitharu.kotatsu.history.data.HistoryWithManga
|
||||||
|
import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class HistoryBackup(
|
||||||
|
@SerialName("manga_id") val mangaId: Long,
|
||||||
|
@SerialName("created_at") val createdAt: Long,
|
||||||
|
@SerialName("updated_at") val updatedAt: Long,
|
||||||
|
@SerialName("chapter_id") val chapterId: Long,
|
||||||
|
@SerialName("page") val page: Int,
|
||||||
|
@SerialName("scroll") val scroll: Float,
|
||||||
|
@SerialName("percent") val percent: Float = PROGRESS_NONE,
|
||||||
|
@SerialName("chapters") val chaptersCount: Int = 0,
|
||||||
|
@SerialName("manga") val manga: MangaBackup,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: HistoryWithManga) : this(
|
||||||
|
mangaId = entity.manga.id,
|
||||||
|
createdAt = entity.history.createdAt,
|
||||||
|
updatedAt = entity.history.updatedAt,
|
||||||
|
chapterId = entity.history.chapterId,
|
||||||
|
page = entity.history.page,
|
||||||
|
scroll = entity.history.scroll,
|
||||||
|
percent = entity.history.percent,
|
||||||
|
chaptersCount = entity.history.chaptersCount,
|
||||||
|
manga = MangaBackup(MangaWithTags(entity.manga, entity.tags)),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = HistoryEntity(
|
||||||
|
mangaId = mangaId,
|
||||||
|
createdAt = createdAt,
|
||||||
|
updatedAt = updatedAt,
|
||||||
|
chapterId = chapterId,
|
||||||
|
page = page,
|
||||||
|
scroll = scroll,
|
||||||
|
percent = percent,
|
||||||
|
deletedAt = 0L,
|
||||||
|
chaptersCount = chaptersCount,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaWithTags
|
||||||
|
import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
|
||||||
|
import org.koitharu.kotatsu.parsers.util.mapToSet
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class MangaBackup(
|
||||||
|
@SerialName("id") val id: Long,
|
||||||
|
@SerialName("title") val title: String,
|
||||||
|
@SerialName("alt_title") val altTitles: String? = null,
|
||||||
|
@SerialName("url") val url: String,
|
||||||
|
@SerialName("public_url") val publicUrl: String,
|
||||||
|
@SerialName("rating") val rating: Float = RATING_UNKNOWN,
|
||||||
|
@SerialName("nsfw") val isNsfw: Boolean = false,
|
||||||
|
@SerialName("content_rating") val contentRating: String? = null,
|
||||||
|
@SerialName("cover_url") val coverUrl: String,
|
||||||
|
@SerialName("large_cover_url") val largeCoverUrl: String? = null,
|
||||||
|
@SerialName("state") val state: String? = null,
|
||||||
|
@SerialName("author") val authors: String? = null,
|
||||||
|
@SerialName("source") val source: String,
|
||||||
|
@SerialName("tags") val tags: Set<TagBackup> = emptySet(),
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: MangaWithTags) : this(
|
||||||
|
id = entity.manga.id,
|
||||||
|
title = entity.manga.title,
|
||||||
|
altTitles = entity.manga.altTitles,
|
||||||
|
url = entity.manga.url,
|
||||||
|
publicUrl = entity.manga.publicUrl,
|
||||||
|
rating = entity.manga.rating,
|
||||||
|
isNsfw = entity.manga.isNsfw,
|
||||||
|
contentRating = entity.manga.contentRating,
|
||||||
|
coverUrl = entity.manga.coverUrl,
|
||||||
|
largeCoverUrl = entity.manga.largeCoverUrl,
|
||||||
|
state = entity.manga.state,
|
||||||
|
authors = entity.manga.authors,
|
||||||
|
source = entity.manga.source,
|
||||||
|
tags = entity.tags.mapToSet { TagBackup(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = MangaEntity(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
altTitles = altTitles,
|
||||||
|
url = url,
|
||||||
|
publicUrl = publicUrl,
|
||||||
|
rating = rating,
|
||||||
|
isNsfw = isNsfw,
|
||||||
|
contentRating = contentRating,
|
||||||
|
coverUrl = coverUrl,
|
||||||
|
largeCoverUrl = largeCoverUrl,
|
||||||
|
state = state,
|
||||||
|
authors = authors,
|
||||||
|
source = source,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class SourceBackup(
|
||||||
|
@SerialName("source") val source: String,
|
||||||
|
@SerialName("sort_key") val sortKey: Int,
|
||||||
|
@SerialName("used_at") val lastUsedAt: Long,
|
||||||
|
@SerialName("added_in") val addedIn: Int,
|
||||||
|
@SerialName("pinned") val isPinned: Boolean = false,
|
||||||
|
@SerialName("enabled") val isEnabled: Boolean = true, // for compatibility purposes, should be only true
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: MangaSourceEntity) : this(
|
||||||
|
source = entity.source,
|
||||||
|
sortKey = entity.sortKey,
|
||||||
|
lastUsedAt = entity.lastUsedAt,
|
||||||
|
addedIn = entity.addedIn,
|
||||||
|
isPinned = entity.isPinned,
|
||||||
|
isEnabled = entity.isEnabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = MangaSourceEntity(
|
||||||
|
source = source,
|
||||||
|
isEnabled = isEnabled,
|
||||||
|
sortKey = sortKey,
|
||||||
|
addedIn = addedIn,
|
||||||
|
lastUsedAt = lastUsedAt,
|
||||||
|
isPinned = isPinned,
|
||||||
|
cfState = 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class TagBackup(
|
||||||
|
@SerialName("id") val id: Long,
|
||||||
|
@SerialName("title") val title: String,
|
||||||
|
@SerialName("key") val key: String,
|
||||||
|
@SerialName("source") val source: String,
|
||||||
|
@SerialName("pinned") val isPinned: Boolean = false,
|
||||||
|
) {
|
||||||
|
|
||||||
|
constructor(entity: TagEntity) : this(
|
||||||
|
id = entity.id,
|
||||||
|
title = entity.title,
|
||||||
|
key = entity.key,
|
||||||
|
source = entity.source,
|
||||||
|
isPinned = entity.isPinned,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toEntity() = TagEntity(
|
||||||
|
id = id,
|
||||||
|
title = title,
|
||||||
|
key = key,
|
||||||
|
source = source,
|
||||||
|
isPinned = isPinned,
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.domain
|
||||||
|
|
||||||
|
import android.app.backup.BackupAgent
|
||||||
|
import android.app.backup.BackupDataInput
|
||||||
|
import android.app.backup.BackupDataOutput
|
||||||
|
import android.app.backup.FullBackupDataOutput
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import androidx.annotation.VisibleForTesting
|
||||||
|
import com.google.common.io.ByteStreams
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.koitharu.kotatsu.backups.data.BackupRepository
|
||||||
|
import org.koitharu.kotatsu.core.db.MangaDatabase
|
||||||
|
import org.koitharu.kotatsu.core.prefs.AppSettings
|
||||||
|
import org.koitharu.kotatsu.reader.data.TapGridSettings
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileDescriptor
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.util.EnumSet
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
|
class AppBackupAgent : BackupAgent() {
|
||||||
|
|
||||||
|
override fun onBackup(
|
||||||
|
oldState: ParcelFileDescriptor?,
|
||||||
|
data: BackupDataOutput?,
|
||||||
|
newState: ParcelFileDescriptor?
|
||||||
|
) = Unit
|
||||||
|
|
||||||
|
override fun onRestore(
|
||||||
|
data: BackupDataInput?,
|
||||||
|
appVersionCode: Int,
|
||||||
|
newState: ParcelFileDescriptor?
|
||||||
|
) = Unit
|
||||||
|
|
||||||
|
override fun onFullBackup(data: FullBackupDataOutput) {
|
||||||
|
super.onFullBackup(data)
|
||||||
|
val file =
|
||||||
|
createBackupFile(
|
||||||
|
this,
|
||||||
|
BackupRepository(
|
||||||
|
MangaDatabase(context = applicationContext),
|
||||||
|
AppSettings(applicationContext),
|
||||||
|
TapGridSettings(applicationContext),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
fullBackupFile(file, data)
|
||||||
|
} finally {
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRestoreFile(
|
||||||
|
data: ParcelFileDescriptor,
|
||||||
|
size: Long,
|
||||||
|
destination: File?,
|
||||||
|
type: Int,
|
||||||
|
mode: Long,
|
||||||
|
mtime: Long
|
||||||
|
) {
|
||||||
|
if (destination?.name?.endsWith(".bk.zip") == true) {
|
||||||
|
restoreBackupFile(
|
||||||
|
data.fileDescriptor,
|
||||||
|
size,
|
||||||
|
BackupRepository(
|
||||||
|
database = MangaDatabase(applicationContext),
|
||||||
|
settings = AppSettings(applicationContext),
|
||||||
|
tapGridSettings = TapGridSettings(applicationContext),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
destination.delete()
|
||||||
|
} else {
|
||||||
|
super.onRestoreFile(data, size, destination, type, mode, mtime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
fun createBackupFile(context: Context, repository: BackupRepository): File {
|
||||||
|
val file = BackupUtils.createTempFile(context)
|
||||||
|
ZipOutputStream(file.outputStream()).use { output ->
|
||||||
|
runBlocking {
|
||||||
|
repository.createBackup(output, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
@VisibleForTesting
|
||||||
|
fun restoreBackupFile(fd: FileDescriptor, size: Long, repository: BackupRepository) {
|
||||||
|
ZipInputStream(ByteStreams.limit(FileInputStream(fd), size)).use { input ->
|
||||||
|
runBlocking {
|
||||||
|
repository.restoreBackup(input, EnumSet.allOf(BackupSection::class.java), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
package org.koitharu.kotatsu.backups.domain
|
||||||
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.domain
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
|
||||||
|
enum class BackupSection(
|
||||||
|
val entryName: String,
|
||||||
|
) {
|
||||||
|
|
||||||
|
INDEX("index"),
|
||||||
|
HISTORY("history"),
|
||||||
|
CATEGORIES("categories"),
|
||||||
|
FAVOURITES("favourites"),
|
||||||
|
SETTINGS("settings"),
|
||||||
|
SETTINGS_READER_GRID("reader_grid"),
|
||||||
|
BOOKMARKS("bookmarks"),
|
||||||
|
SOURCES("sources"),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
fun of(entry: ZipEntry): BackupSection? {
|
||||||
|
val name = entry.name.lowercase(Locale.ROOT)
|
||||||
|
return entries.first { x -> x.entryName == name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.domain
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.annotation.CheckResult
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import java.io.File
|
||||||
|
import java.text.ParseException
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
object BackupUtils {
|
||||||
|
|
||||||
|
private const val DIR_BACKUPS = "backups"
|
||||||
|
private val dateTimeFormat = SimpleDateFormat("yyyyMMdd-HHmm")
|
||||||
|
|
||||||
|
@CheckResult
|
||||||
|
fun createTempFile(context: Context): File {
|
||||||
|
val dir = getAppBackupDir(context)
|
||||||
|
dir.mkdirs()
|
||||||
|
return File(dir, generateFileName(context))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAppBackupDir(context: Context) = context.run {
|
||||||
|
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseBackupDateTime(fileName: String): Date? = try {
|
||||||
|
dateTimeFormat.parse(fileName.substringAfterLast('_').substringBefore('.'))
|
||||||
|
} catch (e: ParseException) {
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun generateFileName(context: Context) = buildString {
|
||||||
|
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
||||||
|
append('_')
|
||||||
|
append(dateTimeFormat.format(Date()))
|
||||||
|
append(".bk.zip")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import androidx.core.app.NotificationChannelCompat
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
||||||
|
|
||||||
|
abstract class BaseBackupRestoreService : CoroutineIntentService() {
|
||||||
|
|
||||||
|
protected abstract val notificationTag: String
|
||||||
|
|
||||||
|
protected lateinit var notificationManager: NotificationManagerCompat
|
||||||
|
private set
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||||
|
createNotificationChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun IntentJobContext.onError(error: Throwable) {
|
||||||
|
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
||||||
|
val notification = createErrorNotification(error)
|
||||||
|
notificationManager.notify(notificationTag, startId, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_HIGH)
|
||||||
|
.setName(getString(R.string.backup_restore))
|
||||||
|
.setShowBadge(true)
|
||||||
|
.setVibrationEnabled(false)
|
||||||
|
.setSound(null, null)
|
||||||
|
.setLightsEnabled(false)
|
||||||
|
.build()
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected fun createErrorNotification(error: Throwable): Notification {
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setSilent(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentText(error.getDisplayMessage(resources))
|
||||||
|
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||||
|
ErrorReporterReceiver.getPendingIntent(applicationContext, error)?.let { reportIntent ->
|
||||||
|
notification.addAction(
|
||||||
|
R.drawable.ic_alert_outline,
|
||||||
|
applicationContext.getString(R.string.report),
|
||||||
|
reportIntent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
notification.setContentIntent(
|
||||||
|
PendingIntentCompat.getActivity(
|
||||||
|
applicationContext,
|
||||||
|
0,
|
||||||
|
AppRouter.homeIntent(this),
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return notification.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
protected companion object {
|
||||||
|
|
||||||
|
const val CHANNEL_ID = "backup_restore"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,142 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.backup
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Notification
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.annotation.CheckResult
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import androidx.core.app.ShareCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.backups.data.BackupRepository
|
||||||
|
import org.koitharu.kotatsu.backups.ui.BaseBackupRestoreService
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.powerManager
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock
|
||||||
|
import org.koitharu.kotatsu.core.util.progress.Progress
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
import androidx.appcompat.R as appcompatR
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
@SuppressLint("InlinedApi")
|
||||||
|
class BackupService : BaseBackupRestoreService() {
|
||||||
|
|
||||||
|
override val notificationTag = TAG
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var repository: BackupRepository
|
||||||
|
|
||||||
|
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||||
|
val notification = buildNotification(Progress.INDETERMINATE)
|
||||||
|
setForeground(
|
||||||
|
FOREGROUND_NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||||
|
)
|
||||||
|
val destination = intent.getStringExtra(AppRouter.KEY_DATA)?.toUriOrNull() ?: throw FileNotFoundException()
|
||||||
|
powerManager.withPartialWakeLock(TAG) {
|
||||||
|
val progress = MutableStateFlow(Progress.INDETERMINATE)
|
||||||
|
val progressUpdateJob = if (checkNotificationPermission(CHANNEL_ID)) {
|
||||||
|
launch {
|
||||||
|
progress.collect {
|
||||||
|
notificationManager.notify(FOREGROUND_NOTIFICATION_ID, buildNotification(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
ZipOutputStream(contentResolver.openOutputStream(destination)).use { output ->
|
||||||
|
repository.createBackup(output, progress)
|
||||||
|
}
|
||||||
|
progressUpdateJob?.cancelAndJoin()
|
||||||
|
contentResolver.notifyChange(destination, null)
|
||||||
|
if (checkNotificationPermission(CHANNEL_ID)) {
|
||||||
|
notificationManager.notify(notificationTag, startId, createResultNotification(destination))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun IntentJobContext.buildNotification(progress: Progress): Notification {
|
||||||
|
return NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setContentTitle(getString(R.string.creating_backup))
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setSilent(true)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setProgress(
|
||||||
|
progress.total.coerceAtLeast(0),
|
||||||
|
progress.progress.coerceAtLeast(0),
|
||||||
|
progress.isIndeterminate,
|
||||||
|
)
|
||||||
|
.setContentText(
|
||||||
|
if (progress.isIndeterminate) {
|
||||||
|
getString(R.string.processing_)
|
||||||
|
} else {
|
||||||
|
getString(R.string.fraction_pattern, progress.progress, progress.total)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_upload)
|
||||||
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
|
.addAction(
|
||||||
|
appcompatR.drawable.abc_ic_clear_material,
|
||||||
|
applicationContext.getString(android.R.string.cancel),
|
||||||
|
getCancelIntent(),
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createResultNotification(uri: Uri): Notification {
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setSilent(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentText(getString(R.string.backup_saved))
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_done)
|
||||||
|
val shareIntent = ShareCompat.IntentBuilder(this)
|
||||||
|
.setStream(uri)
|
||||||
|
.setType(contentResolver.getType(uri) ?: "application/zip")
|
||||||
|
.setChooserTitle(R.string.share_backup)
|
||||||
|
.createChooserIntent()
|
||||||
|
notification.setContentIntent(
|
||||||
|
PendingIntentCompat.getActivity(
|
||||||
|
applicationContext,
|
||||||
|
0,
|
||||||
|
shareIntent,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return notification.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val TAG = "BACKUP"
|
||||||
|
private const val FOREGROUND_NOTIFICATION_ID = 33
|
||||||
|
|
||||||
|
@CheckResult
|
||||||
|
fun start(context: Context, uri: Uri): Boolean = try {
|
||||||
|
val intent = Intent(context, BackupService::class.java)
|
||||||
|
intent.putExtra(AppRouter.KEY_DATA, uri.toString())
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.backup
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import org.koitharu.kotatsu.backups.data.BackupRepository
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.call
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.require
|
||||||
|
import org.koitharu.kotatsu.core.util.progress.Progress
|
||||||
|
import java.util.zip.Deflater
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class BackupViewModel @Inject constructor(
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
private val repository: BackupRepository,
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val progress = MutableStateFlow(Progress.INDETERMINATE)
|
||||||
|
val onBackupDone = MutableEventFlow<Uri>()
|
||||||
|
|
||||||
|
private val destination = savedStateHandle.require<Uri>(AppRouter.KEY_DATA)
|
||||||
|
private val contentResolver: ContentResolver = context.contentResolver
|
||||||
|
|
||||||
|
init {
|
||||||
|
launchLoadingJob(Dispatchers.Default) {
|
||||||
|
ZipOutputStream(checkNotNull(contentResolver.openOutputStream(destination))).use {
|
||||||
|
it.setLevel(Deflater.BEST_COMPRESSION)
|
||||||
|
repository.createBackup(it, progress)
|
||||||
|
}
|
||||||
|
onBackupDone.call(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.restore
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.backups.domain.BackupSection
|
||||||
|
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
||||||
|
import org.koitharu.kotatsu.list.ui.model.ListModel
|
||||||
|
|
||||||
|
data class BackupSectionModel(
|
||||||
|
val section: BackupSection,
|
||||||
|
val isChecked: Boolean,
|
||||||
|
val isEnabled: Boolean,
|
||||||
|
) : ListModel {
|
||||||
|
|
||||||
|
@get:StringRes
|
||||||
|
val titleResId: Int
|
||||||
|
get() = when (section) {
|
||||||
|
BackupSection.INDEX -> 0 // should not appear here
|
||||||
|
BackupSection.HISTORY -> R.string.history
|
||||||
|
BackupSection.CATEGORIES -> R.string.favourites_categories
|
||||||
|
BackupSection.FAVOURITES -> R.string.favourites
|
||||||
|
BackupSection.SETTINGS -> R.string.settings
|
||||||
|
BackupSection.SETTINGS_READER_GRID -> R.string.reader_actions
|
||||||
|
BackupSection.BOOKMARKS -> R.string.bookmarks
|
||||||
|
BackupSection.SOURCES -> R.string.remote_sources
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areItemsTheSame(other: ListModel): Boolean {
|
||||||
|
return other is BackupSectionModel && other.section == section
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getChangePayload(previousState: ListModel): Any? {
|
||||||
|
if (previousState !is BackupSectionModel) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return if (previousState.isEnabled != isEnabled) {
|
||||||
|
ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED
|
||||||
|
} else if (previousState.isChecked != isChecked) {
|
||||||
|
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
|
||||||
|
} else {
|
||||||
|
super.getChangePayload(previousState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,146 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.restore
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Notification
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.annotation.CheckResult
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.PendingIntentCompat
|
||||||
|
import androidx.core.app.ShareCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koitharu.kotatsu.R
|
||||||
|
import org.koitharu.kotatsu.backups.data.BackupRepository
|
||||||
|
import org.koitharu.kotatsu.backups.domain.BackupSection
|
||||||
|
import org.koitharu.kotatsu.backups.ui.BaseBackupRestoreService
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.powerManager
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock
|
||||||
|
import org.koitharu.kotatsu.core.util.progress.Progress
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
import androidx.appcompat.R as appcompatR
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
@SuppressLint("InlinedApi")
|
||||||
|
class RestoreService : BaseBackupRestoreService() {
|
||||||
|
|
||||||
|
override val notificationTag = TAG
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
lateinit var repository: BackupRepository
|
||||||
|
|
||||||
|
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
||||||
|
val notification = buildNotification(Progress.INDETERMINATE)
|
||||||
|
setForeground(
|
||||||
|
FOREGROUND_NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
||||||
|
)
|
||||||
|
val source = intent.getStringExtra(AppRouter.KEY_DATA)?.toUriOrNull() ?: throw FileNotFoundException()
|
||||||
|
val sections =
|
||||||
|
requireNotNull(intent.getSerializableExtraCompat<Array<BackupSection>>(AppRouter.KEY_ENTRIES)?.toSet())
|
||||||
|
powerManager.withPartialWakeLock(TAG) {
|
||||||
|
val progress = MutableStateFlow(Progress.INDETERMINATE)
|
||||||
|
val progressUpdateJob = if (checkNotificationPermission(CHANNEL_ID)) {
|
||||||
|
launch {
|
||||||
|
progress.collect {
|
||||||
|
notificationManager.notify(FOREGROUND_NOTIFICATION_ID, buildNotification(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
ZipInputStream(contentResolver.openInputStream(source)).use { input ->
|
||||||
|
repository.restoreBackup(input, sections, progress)
|
||||||
|
}
|
||||||
|
progressUpdateJob?.cancelAndJoin()
|
||||||
|
if (checkNotificationPermission(CHANNEL_ID)) {
|
||||||
|
notificationManager.notify(notificationTag, startId, createResultNotification(source))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun IntentJobContext.buildNotification(progress: Progress): Notification {
|
||||||
|
return NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setContentTitle(getString(R.string.restoring_backup))
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setSilent(true)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setProgress(
|
||||||
|
progress.total.coerceAtLeast(0),
|
||||||
|
progress.progress.coerceAtLeast(0),
|
||||||
|
progress.isIndeterminate,
|
||||||
|
)
|
||||||
|
.setContentText(
|
||||||
|
if (progress.isIndeterminate) {
|
||||||
|
getString(R.string.processing_)
|
||||||
|
} else {
|
||||||
|
getString(R.string.fraction_pattern, progress.progress, progress.total)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.setSmallIcon(android.R.drawable.stat_sys_upload)
|
||||||
|
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
||||||
|
.addAction(
|
||||||
|
appcompatR.drawable.abc_ic_clear_material,
|
||||||
|
applicationContext.getString(android.R.string.cancel),
|
||||||
|
getCancelIntent(),
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createResultNotification(uri: Uri): Notification {
|
||||||
|
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setDefaults(0)
|
||||||
|
.setSilent(true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setContentText(getString(R.string.backup_saved))
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_done)
|
||||||
|
val shareIntent = ShareCompat.IntentBuilder(this)
|
||||||
|
.setStream(uri)
|
||||||
|
.setType(contentResolver.getType(uri) ?: "application/zip")
|
||||||
|
.setChooserTitle(R.string.share_backup)
|
||||||
|
.createChooserIntent()
|
||||||
|
notification.setContentIntent(
|
||||||
|
PendingIntentCompat.getActivity(
|
||||||
|
applicationContext,
|
||||||
|
0,
|
||||||
|
shareIntent,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return notification.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
private const val TAG = "RESTORE"
|
||||||
|
private const val FOREGROUND_NOTIFICATION_ID = 39
|
||||||
|
|
||||||
|
@CheckResult
|
||||||
|
fun start(context: Context, uri: Uri, sections: Set<BackupSection>): Boolean = try {
|
||||||
|
val intent = Intent(context, RestoreService::class.java)
|
||||||
|
intent.putExtra(AppRouter.KEY_DATA, uri.toString())
|
||||||
|
intent.putExtra(AppRouter.KEY_ENTRIES, sections.toTypedArray())
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,112 @@
|
|||||||
|
package org.koitharu.kotatsu.backups.ui.restore
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.decodeFromStream
|
||||||
|
import org.koitharu.kotatsu.backups.data.model.BackupIndex
|
||||||
|
import org.koitharu.kotatsu.backups.domain.BackupSection
|
||||||
|
import org.koitharu.kotatsu.core.nav.AppRouter
|
||||||
|
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
||||||
|
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.EnumMap
|
||||||
|
import java.util.EnumSet
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class RestoreViewModel @Inject constructor(
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
) : BaseViewModel() {
|
||||||
|
|
||||||
|
val uri = savedStateHandle.get<String>(AppRouter.KEY_FILE)?.toUriOrNull()
|
||||||
|
private val contentResolver = context.contentResolver
|
||||||
|
|
||||||
|
val availableEntries = MutableStateFlow<List<BackupSectionModel>>(emptyList())
|
||||||
|
val backupDate = MutableStateFlow<Date?>(null)
|
||||||
|
|
||||||
|
init {
|
||||||
|
launchLoadingJob(Dispatchers.Default) {
|
||||||
|
loadBackupInfo()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadBackupInfo() {
|
||||||
|
val sections = runInterruptible(Dispatchers.IO) {
|
||||||
|
if (uri == null) throw FileNotFoundException()
|
||||||
|
ZipInputStream(contentResolver.openInputStream(uri)).use { stream ->
|
||||||
|
val result = EnumSet.noneOf(BackupSection::class.java)
|
||||||
|
var entry = stream.nextEntry
|
||||||
|
while (entry != null) {
|
||||||
|
val s = BackupSection.of(entry)
|
||||||
|
if (s != null) {
|
||||||
|
result.add(s)
|
||||||
|
if (s == BackupSection.INDEX) {
|
||||||
|
backupDate.value = stream.readDate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stream.closeEntry()
|
||||||
|
entry = stream.nextEntry
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
availableEntries.value = BackupSection.entries.mapNotNull { entry ->
|
||||||
|
if (entry == BackupSection.INDEX || entry !in sections) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
BackupSectionModel(
|
||||||
|
section = entry,
|
||||||
|
isChecked = true,
|
||||||
|
isEnabled = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onItemClick(item: BackupSectionModel) {
|
||||||
|
val map = availableEntries.value.associateByTo(EnumMap(BackupSection::class.java)) { it.section }
|
||||||
|
map[item.section] = item.copy(isChecked = !item.isChecked)
|
||||||
|
map.validate()
|
||||||
|
availableEntries.value = map.values.sortedBy { it.section.ordinal }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCheckedSections(): Set<BackupSection> = availableEntries.value
|
||||||
|
.mapNotNullTo(EnumSet.noneOf(BackupSection::class.java)) {
|
||||||
|
if (it.isChecked) it.section else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for inconsistent user selection
|
||||||
|
* Favorites cannot be restored without categories
|
||||||
|
*/
|
||||||
|
private fun MutableMap<BackupSection, BackupSectionModel>.validate() {
|
||||||
|
val favorites = this[BackupSection.FAVOURITES] ?: return
|
||||||
|
val categories = this[BackupSection.CATEGORIES]
|
||||||
|
if (categories?.isChecked == true) {
|
||||||
|
if (!favorites.isEnabled) {
|
||||||
|
this[BackupSection.FAVOURITES] = favorites.copy(isEnabled = true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (favorites.isEnabled) {
|
||||||
|
this[BackupSection.FAVOURITES] = favorites.copy(isEnabled = false, isChecked = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun InputStream.readDate(): Date? = runCatching {
|
||||||
|
val index = Json.decodeFromStream<List<BackupIndex>>(this)
|
||||||
|
Date(index.single().createdAt)
|
||||||
|
}.onFailure { e ->
|
||||||
|
e.printStackTraceDebug()
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
@ -1,23 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import org.json.JSONArray
|
|
||||||
|
|
||||||
class BackupEntry(
|
|
||||||
val name: Name,
|
|
||||||
val data: JSONArray
|
|
||||||
) {
|
|
||||||
|
|
||||||
enum class Name(
|
|
||||||
val key: String,
|
|
||||||
) {
|
|
||||||
|
|
||||||
INDEX("index"),
|
|
||||||
HISTORY("history"),
|
|
||||||
CATEGORIES("categories"),
|
|
||||||
FAVOURITES("favourites"),
|
|
||||||
SETTINGS("settings"),
|
|
||||||
SETTINGS_READER_GRID("reader_grid"),
|
|
||||||
BOOKMARKS("bookmarks"),
|
|
||||||
SOURCES("sources"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,259 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import androidx.room.withTransaction
|
|
||||||
import kotlinx.coroutines.flow.FlowCollector
|
|
||||||
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.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.core.util.progress.Progress
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.asTypedList
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.mapJSON
|
|
||||||
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
|
|
||||||
import org.koitharu.kotatsu.reader.data.TapGridSettings
|
|
||||||
import java.util.Date
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
private const val PAGE_SIZE = 10
|
|
||||||
|
|
||||||
class BackupRepository @Inject constructor(
|
|
||||||
private val db: MangaDatabase,
|
|
||||||
private val settings: AppSettings,
|
|
||||||
private val tapGridSettings: TapGridSettings,
|
|
||||||
) {
|
|
||||||
|
|
||||||
suspend fun dumpHistory(): BackupEntry {
|
|
||||||
var offset = 0
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray())
|
|
||||||
while (true) {
|
|
||||||
val history = db.getHistoryDao().findAll(offset = offset, limit = PAGE_SIZE)
|
|
||||||
if (history.isEmpty()) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
offset += history.size
|
|
||||||
for (item in history) {
|
|
||||||
val manga = JsonSerializer(item.manga).toJson()
|
|
||||||
val tags = JSONArray()
|
|
||||||
item.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
|
|
||||||
manga.put("tags", tags)
|
|
||||||
val json = JsonSerializer(item.history).toJson()
|
|
||||||
json.put("manga", manga)
|
|
||||||
entry.data.put(json)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun dumpCategories(): BackupEntry {
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.CATEGORIES, JSONArray())
|
|
||||||
val categories = db.getFavouriteCategoriesDao().findAll()
|
|
||||||
for (item in categories) {
|
|
||||||
entry.data.put(JsonSerializer(item).toJson())
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun dumpFavourites(): BackupEntry {
|
|
||||||
var offset = 0
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray())
|
|
||||||
while (true) {
|
|
||||||
val favourites = db.getFavouritesDao().findAllRaw(offset = offset, limit = PAGE_SIZE)
|
|
||||||
if (favourites.isEmpty()) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
offset += favourites.size
|
|
||||||
for (item in favourites) {
|
|
||||||
val manga = JsonSerializer(item.manga).toJson()
|
|
||||||
val tags = JSONArray()
|
|
||||||
item.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
|
|
||||||
manga.put("tags", tags)
|
|
||||||
val json = JsonSerializer(item.favourite).toJson()
|
|
||||||
json.put("manga", manga)
|
|
||||||
entry.data.put(json)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun dumpBookmarks(): BackupEntry {
|
|
||||||
var offset = 0
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray())
|
|
||||||
while (true) {
|
|
||||||
val bookmarks = db.getBookmarksDao().findAll(offset = offset, limit = PAGE_SIZE)
|
|
||||||
if (bookmarks.isEmpty()) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
offset += bookmarks.size
|
|
||||||
for ((m, b) in bookmarks) {
|
|
||||||
val json = JSONObject()
|
|
||||||
val manga = JsonSerializer(m.manga).toJson()
|
|
||||||
json.put("manga", manga)
|
|
||||||
val tags = JSONArray()
|
|
||||||
m.tags.forEach { tags.put(JsonSerializer(it).toJson()) }
|
|
||||||
json.put("tags", tags)
|
|
||||||
val bookmarks = JSONArray()
|
|
||||||
b.forEach { bookmarks.put(JsonSerializer(it).toJson()) }
|
|
||||||
json.put("bookmarks", bookmarks)
|
|
||||||
entry.data.put(json)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dumpSettings(): BackupEntry {
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.SETTINGS, JSONArray())
|
|
||||||
val settingsDump = settings.getAllValues().toMutableMap()
|
|
||||||
settingsDump.remove(AppSettings.KEY_APP_PASSWORD)
|
|
||||||
settingsDump.remove(AppSettings.KEY_PROXY_PASSWORD)
|
|
||||||
settingsDump.remove(AppSettings.KEY_PROXY_LOGIN)
|
|
||||||
settingsDump.remove(AppSettings.KEY_INCOGNITO_MODE)
|
|
||||||
val json = JsonSerializer(settingsDump).toJson()
|
|
||||||
entry.data.put(json)
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dumpReaderGridSettings(): BackupEntry {
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.SETTINGS_READER_GRID, JSONArray())
|
|
||||||
val settingsDump = tapGridSettings.getAllValues()
|
|
||||||
val json = JsonSerializer(settingsDump).toJson()
|
|
||||||
entry.data.put(json)
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun dumpSources(): BackupEntry {
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray())
|
|
||||||
val all = db.getSourcesDao().findAll()
|
|
||||||
for (source in all) {
|
|
||||||
val json = JsonSerializer(source).toJson()
|
|
||||||
entry.data.put(json)
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createIndex(): BackupEntry {
|
|
||||||
val entry = BackupEntry(BackupEntry.Name.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
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getBackupDate(entry: BackupEntry?): Date? {
|
|
||||||
val timestamp = entry?.data?.optJSONObject(0)?.getLongOrDefault("created_at", 0) ?: 0
|
|
||||||
return if (timestamp == 0L) null else Date(timestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreHistory(entry: BackupEntry, outProgress: FlowCollector<Progress>?): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
val list = entry.data.asTypedList<JSONObject>()
|
|
||||||
outProgress?.emit(Progress(progress = 0, total = list.size))
|
|
||||||
for ((index, item) in list.withIndex()) {
|
|
||||||
val mangaJson = item.getJSONObject("manga")
|
|
||||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
|
||||||
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
|
||||||
JsonDeserializer(it).toTagEntity()
|
|
||||||
}
|
|
||||||
val history = JsonDeserializer(item).toHistoryEntity()
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
db.withTransaction {
|
|
||||||
db.getTagsDao().upsert(tags)
|
|
||||||
db.getMangaDao().upsert(manga, tags)
|
|
||||||
db.getHistoryDao().upsert(history)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
outProgress?.emit(Progress(progress = index, total = list.size))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreCategories(entry: BackupEntry): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
|
||||||
val category = JsonDeserializer(item).toFavouriteCategoryEntity()
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
db.getFavouriteCategoriesDao().upsert(category)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreFavourites(entry: BackupEntry, outProgress: FlowCollector<Progress>?): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
val list = entry.data.asTypedList<JSONObject>()
|
|
||||||
outProgress?.emit(Progress(progress = 0, total = list.size))
|
|
||||||
for ((index, item) in list.withIndex()) {
|
|
||||||
val mangaJson = item.getJSONObject("manga")
|
|
||||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
|
||||||
val tags = mangaJson.getJSONArray("tags").mapJSON {
|
|
||||||
JsonDeserializer(it).toTagEntity()
|
|
||||||
}
|
|
||||||
val favourite = JsonDeserializer(item).toFavouriteEntity()
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
db.withTransaction {
|
|
||||||
db.getTagsDao().upsert(tags)
|
|
||||||
db.getMangaDao().upsert(manga, tags)
|
|
||||||
db.getFavouritesDao().upsert(favourite)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
outProgress?.emit(Progress(progress = index, total = list.size))
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreBookmarks(entry: BackupEntry): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
|
||||||
val mangaJson = item.getJSONObject("manga")
|
|
||||||
val manga = JsonDeserializer(mangaJson).toMangaEntity()
|
|
||||||
val tags = item.getJSONArray("tags").mapJSON {
|
|
||||||
JsonDeserializer(it).toTagEntity()
|
|
||||||
}
|
|
||||||
val bookmarks = item.getJSONArray("bookmarks").mapJSON {
|
|
||||||
JsonDeserializer(it).toBookmarkEntity()
|
|
||||||
}
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
db.withTransaction {
|
|
||||||
db.getTagsDao().upsert(tags)
|
|
||||||
db.getMangaDao().upsert(manga, tags)
|
|
||||||
db.getBookmarksDao().upsert(bookmarks)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun restoreSources(entry: BackupEntry): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
|
||||||
val source = JsonDeserializer(item).toMangaSourceEntity()
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
db.getSourcesDao().upsert(source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun restoreSettings(entry: BackupEntry): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
settings.upsertAll(JsonDeserializer(item).toMap())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun restoreReaderGridSettings(entry: BackupEntry): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
for (item in entry.data.asTypedList<JSONObject>()) {
|
|
||||||
result += runCatchingCancellable {
|
|
||||||
tapGridSettings.upsertAll(JsonDeserializer(item).toMap())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import okhttp3.internal.closeQuietly
|
|
||||||
import okio.Closeable
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
|
|
||||||
import java.io.File
|
|
||||||
import java.util.EnumSet
|
|
||||||
import java.util.zip.ZipException
|
|
||||||
import java.util.zip.ZipFile
|
|
||||||
|
|
||||||
class BackupZipInput private constructor(val file: File) : Closeable {
|
|
||||||
|
|
||||||
private val zipFile = ZipFile(file)
|
|
||||||
|
|
||||||
suspend fun getEntry(name: BackupEntry.Name): BackupEntry? = runInterruptible(Dispatchers.IO) {
|
|
||||||
val entry = zipFile.getEntry(name.key) ?: return@runInterruptible null
|
|
||||||
val json = zipFile.getInputStream(entry).use {
|
|
||||||
JSONArray(it.bufferedReader().readText())
|
|
||||||
}
|
|
||||||
BackupEntry(name, json)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun entries(): Set<BackupEntry.Name> = runInterruptible(Dispatchers.IO) {
|
|
||||||
zipFile.entries().toList().mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { ze ->
|
|
||||||
BackupEntry.Name.entries.find { it.key == ze.name }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
|
||||||
zipFile.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun closeAndDelete() {
|
|
||||||
closeQuietly()
|
|
||||||
file.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
fun from(file: File): BackupZipInput {
|
|
||||||
var res: BackupZipInput? = null
|
|
||||||
return try {
|
|
||||||
res = BackupZipInput(file)
|
|
||||||
if (res.zipFile.getEntry("index") == null) {
|
|
||||||
throw BadBackupFormatException(null)
|
|
||||||
}
|
|
||||||
res
|
|
||||||
} catch (exception: Throwable) {
|
|
||||||
res?.closeQuietly()
|
|
||||||
throw if (exception is ZipException) {
|
|
||||||
BadBackupFormatException(exception)
|
|
||||||
} else {
|
|
||||||
exception
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import okio.Closeable
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
import org.koitharu.kotatsu.core.zip.ZipOutput
|
|
||||||
import java.io.File
|
|
||||||
import java.text.ParseException
|
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.Locale
|
|
||||||
import java.util.zip.Deflater
|
|
||||||
|
|
||||||
class BackupZipOutput(val file: File) : Closeable {
|
|
||||||
|
|
||||||
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
|
|
||||||
|
|
||||||
suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) {
|
|
||||||
output.put(entry.name.key, entry.data.toString(2))
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun finish() = runInterruptible(Dispatchers.IO) {
|
|
||||||
output.finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun close() {
|
|
||||||
output.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
const val DIR_BACKUPS = "backups"
|
|
||||||
private val dateTimeFormat = SimpleDateFormat("yyyyMMdd-HHmm")
|
|
||||||
|
|
||||||
fun generateFileName(context: Context) = buildString {
|
|
||||||
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
|
|
||||||
append('_')
|
|
||||||
append(dateTimeFormat.format(Date()))
|
|
||||||
append(".bk.zip")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun parseBackupDateTime(fileName: String): Date? = try {
|
|
||||||
dateTimeFormat.parse(fileName.substringAfterLast('_').substringBefore('.'))
|
|
||||||
} catch (e: ParseException) {
|
|
||||||
e.printStackTraceDebug()
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun createTemp(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
|
|
||||||
val dir = context.run {
|
|
||||||
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
|
|
||||||
}
|
|
||||||
dir.mkdirs()
|
|
||||||
BackupZipOutput(File(dir, generateFileName(context)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
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 isEmpty: Boolean
|
|
||||||
get() = errors.isEmpty() && successCount == 0
|
|
||||||
|
|
||||||
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(error: Throwable) {
|
|
||||||
errors.add(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,110 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
|
||||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
|
||||||
import org.koitharu.kotatsu.parsers.network.CloudFlareHelper
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
|
|
||||||
import org.koitharu.kotatsu.parsers.util.json.getStringOrNull
|
|
||||||
|
|
||||||
class JsonDeserializer(private val json: JSONObject) {
|
|
||||||
|
|
||||||
fun toFavouriteEntity() = FavouriteEntity(
|
|
||||||
mangaId = json.getLong("manga_id"),
|
|
||||||
categoryId = json.getLong("category_id"),
|
|
||||||
sortKey = json.getIntOrDefault("sort_key", 0),
|
|
||||||
createdAt = json.getLong("created_at"),
|
|
||||||
deletedAt = 0L,
|
|
||||||
isPinned = json.getBooleanOrDefault("pinned", false),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toMangaEntity() = MangaEntity(
|
|
||||||
id = json.getLong("id"),
|
|
||||||
title = json.getString("title"),
|
|
||||||
altTitles = json.getStringOrNull("alt_title"),
|
|
||||||
url = json.getString("url"),
|
|
||||||
publicUrl = json.getStringOrNull("public_url").orEmpty(),
|
|
||||||
rating = json.getDouble("rating").toFloat(),
|
|
||||||
isNsfw = json.getBooleanOrDefault("nsfw", false),
|
|
||||||
contentRating = json.getStringOrNull("content_rating"),
|
|
||||||
coverUrl = json.getString("cover_url"),
|
|
||||||
largeCoverUrl = json.getStringOrNull("large_cover_url"),
|
|
||||||
state = json.getStringOrNull("state"),
|
|
||||||
authors = json.getStringOrNull("author"),
|
|
||||||
source = json.getString("source"),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toTagEntity() = TagEntity(
|
|
||||||
id = json.getLong("id"),
|
|
||||||
title = json.getString("title"),
|
|
||||||
key = json.getString("key"),
|
|
||||||
source = json.getString("source"),
|
|
||||||
isPinned = json.getBooleanOrDefault("pinned", false),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toHistoryEntity() = 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(),
|
|
||||||
percent = json.getFloatOrDefault("percent", -1f),
|
|
||||||
chaptersCount = json.getIntOrDefault("chapters", -1),
|
|
||||||
deletedAt = 0L,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toFavouriteCategoryEntity() = FavouriteCategoryEntity(
|
|
||||||
categoryId = json.getInt("category_id"),
|
|
||||||
createdAt = json.getLong("created_at"),
|
|
||||||
sortKey = json.getInt("sort_key"),
|
|
||||||
title = json.getString("title"),
|
|
||||||
order = json.getStringOrNull("order") ?: SortOrder.NEWEST.name,
|
|
||||||
track = json.getBooleanOrDefault("track", true),
|
|
||||||
isVisibleInLibrary = json.getBooleanOrDefault("show_in_lib", true),
|
|
||||||
deletedAt = 0L,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toBookmarkEntity() = BookmarkEntity(
|
|
||||||
mangaId = json.getLong("manga_id"),
|
|
||||||
pageId = json.getLong("page_id"),
|
|
||||||
chapterId = json.getLong("chapter_id"),
|
|
||||||
page = json.getInt("page"),
|
|
||||||
scroll = json.getInt("scroll"),
|
|
||||||
imageUrl = json.getString("image_url"),
|
|
||||||
createdAt = json.getLong("created_at"),
|
|
||||||
percent = json.getDouble("percent").toFloat(),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toMangaSourceEntity() = MangaSourceEntity(
|
|
||||||
source = json.getString("source"),
|
|
||||||
isEnabled = json.getBoolean("enabled"),
|
|
||||||
sortKey = json.getInt("sort_key"),
|
|
||||||
addedIn = json.getIntOrDefault("added_in", 0),
|
|
||||||
lastUsedAt = json.getLongOrDefault("used_at", 0L),
|
|
||||||
isPinned = json.getBooleanOrDefault("pinned", false),
|
|
||||||
cfState = json.getIntOrDefault("cf_state", CloudFlareHelper.PROTECTION_NOT_DETECTED),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toMap(): Map<String, Any?> {
|
|
||||||
val map = mutableMapOf<String, Any?>()
|
|
||||||
val keys = json.keys()
|
|
||||||
|
|
||||||
while (keys.hasNext()) {
|
|
||||||
val key = keys.next()
|
|
||||||
val value = json.get(key)
|
|
||||||
map[key] = value
|
|
||||||
}
|
|
||||||
|
|
||||||
return map
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import org.json.JSONObject
|
|
||||||
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
|
||||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
|
||||||
|
|
||||||
class JsonSerializer private constructor(private val json: JSONObject) {
|
|
||||||
|
|
||||||
constructor(e: FavouriteEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("manga_id", e.mangaId)
|
|
||||||
put("category_id", e.categoryId)
|
|
||||||
put("sort_key", e.sortKey)
|
|
||||||
put("created_at", e.createdAt)
|
|
||||||
put("pinned", e.isPinned)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(e: FavouriteCategoryEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("category_id", e.categoryId)
|
|
||||||
put("created_at", e.createdAt)
|
|
||||||
put("sort_key", e.sortKey)
|
|
||||||
put("title", e.title)
|
|
||||||
put("order", e.order)
|
|
||||||
put("track", e.track)
|
|
||||||
put("show_in_lib", e.isVisibleInLibrary)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(e: HistoryEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("manga_id", e.mangaId)
|
|
||||||
put("created_at", e.createdAt)
|
|
||||||
put("updated_at", e.updatedAt)
|
|
||||||
put("chapter_id", e.chapterId)
|
|
||||||
put("page", e.page)
|
|
||||||
put("scroll", e.scroll)
|
|
||||||
put("percent", e.percent)
|
|
||||||
put("chapters", e.chaptersCount)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(e: TagEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("id", e.id)
|
|
||||||
put("title", e.title)
|
|
||||||
put("key", e.key)
|
|
||||||
put("source", e.source)
|
|
||||||
put("pinned", e.isPinned)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(e: MangaEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("id", e.id)
|
|
||||||
put("title", e.title)
|
|
||||||
put("alt_title", e.altTitles)
|
|
||||||
put("url", e.url)
|
|
||||||
put("public_url", e.publicUrl)
|
|
||||||
put("rating", e.rating)
|
|
||||||
put("nsfw", e.isNsfw)
|
|
||||||
put("content_rating", e.contentRating)
|
|
||||||
put("cover_url", e.coverUrl)
|
|
||||||
put("large_cover_url", e.largeCoverUrl)
|
|
||||||
put("state", e.state)
|
|
||||||
put("author", e.authors)
|
|
||||||
put("source", e.source)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(e: BookmarkEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("manga_id", e.mangaId)
|
|
||||||
put("page_id", e.pageId)
|
|
||||||
put("chapter_id", e.chapterId)
|
|
||||||
put("page", e.page)
|
|
||||||
put("scroll", e.scroll)
|
|
||||||
put("image_url", e.imageUrl)
|
|
||||||
put("created_at", e.createdAt)
|
|
||||||
put("percent", e.percent)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(e: MangaSourceEntity) : this(
|
|
||||||
JSONObject().apply {
|
|
||||||
put("source", e.source)
|
|
||||||
put("enabled", e.isEnabled)
|
|
||||||
put("sort_key", e.sortKey)
|
|
||||||
put("added_in", e.addedIn)
|
|
||||||
put("used_at", e.lastUsedAt)
|
|
||||||
put("pinned", e.isPinned)
|
|
||||||
put("cf_state", e.cfState)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
constructor(m: Map<String, *>) : this(
|
|
||||||
JSONObject(m),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toJson(): JSONObject = json
|
|
||||||
}
|
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
package org.koitharu.kotatsu.core.util
|
||||||
|
|
||||||
|
class CompositeResult private constructor(
|
||||||
|
private var successCount: Int,
|
||||||
|
private val errors: List<Throwable>,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val size: Int
|
||||||
|
get() = successCount + errors.size
|
||||||
|
|
||||||
|
val failures: List<Throwable>
|
||||||
|
get() = errors
|
||||||
|
|
||||||
|
val isEmpty: Boolean
|
||||||
|
get() = errors.isEmpty() && successCount == 0
|
||||||
|
|
||||||
|
val isAllSuccess: Boolean
|
||||||
|
get() = errors.isEmpty()
|
||||||
|
|
||||||
|
val isAllFailed: Boolean
|
||||||
|
get() = successCount == 0 && errors.isNotEmpty()
|
||||||
|
|
||||||
|
operator fun plus(result: Result<*>): CompositeResult = CompositeResult(
|
||||||
|
successCount = successCount + if (result.isSuccess) 1 else 0,
|
||||||
|
errors = errors + listOfNotNull(result.exceptionOrNull()),
|
||||||
|
)
|
||||||
|
|
||||||
|
operator fun plus(other: CompositeResult): CompositeResult = CompositeResult(
|
||||||
|
successCount = successCount + other.successCount,
|
||||||
|
errors = errors + other.errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as CompositeResult
|
||||||
|
|
||||||
|
if (successCount != other.successCount) return false
|
||||||
|
if (errors != other.errors) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = successCount
|
||||||
|
result = 31 * result + errors.hashCode()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
val EMPTY = CompositeResult(0, emptyList())
|
||||||
|
|
||||||
|
fun success() = CompositeResult(1, emptyList())
|
||||||
|
|
||||||
|
fun failure(error: Throwable) = CompositeResult(0, listOf(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,141 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.settings.backup
|
|
||||||
|
|
||||||
import android.app.backup.BackupAgent
|
|
||||||
import android.app.backup.BackupDataInput
|
|
||||||
import android.app.backup.BackupDataOutput
|
|
||||||
import android.app.backup.FullBackupDataOutput
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.ParcelFileDescriptor
|
|
||||||
import androidx.annotation.VisibleForTesting
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import org.koitharu.kotatsu.core.backup.BackupEntry
|
|
||||||
import org.koitharu.kotatsu.core.backup.BackupRepository
|
|
||||||
import org.koitharu.kotatsu.core.backup.BackupZipInput
|
|
||||||
import org.koitharu.kotatsu.core.backup.BackupZipOutput
|
|
||||||
import org.koitharu.kotatsu.core.db.MangaDatabase
|
|
||||||
import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException
|
|
||||||
import org.koitharu.kotatsu.core.prefs.AppSettings
|
|
||||||
import org.koitharu.kotatsu.reader.data.TapGridSettings
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileDescriptor
|
|
||||||
import java.io.FileInputStream
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
|
|
||||||
class AppBackupAgent : BackupAgent() {
|
|
||||||
|
|
||||||
override fun onBackup(
|
|
||||||
oldState: ParcelFileDescriptor?,
|
|
||||||
data: BackupDataOutput?,
|
|
||||||
newState: ParcelFileDescriptor?
|
|
||||||
) = Unit
|
|
||||||
|
|
||||||
override fun onRestore(
|
|
||||||
data: BackupDataInput?,
|
|
||||||
appVersionCode: Int,
|
|
||||||
newState: ParcelFileDescriptor?
|
|
||||||
) = Unit
|
|
||||||
|
|
||||||
override fun onFullBackup(data: FullBackupDataOutput) {
|
|
||||||
super.onFullBackup(data)
|
|
||||||
val file =
|
|
||||||
createBackupFile(
|
|
||||||
this,
|
|
||||||
BackupRepository(
|
|
||||||
MangaDatabase(context = applicationContext),
|
|
||||||
AppSettings(applicationContext),
|
|
||||||
TapGridSettings(applicationContext),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
try {
|
|
||||||
fullBackupFile(file, data)
|
|
||||||
} finally {
|
|
||||||
file.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRestoreFile(
|
|
||||||
data: ParcelFileDescriptor,
|
|
||||||
size: Long,
|
|
||||||
destination: File?,
|
|
||||||
type: Int,
|
|
||||||
mode: Long,
|
|
||||||
mtime: Long
|
|
||||||
) {
|
|
||||||
if (destination?.name?.endsWith(".bk.zip") == true) {
|
|
||||||
restoreBackupFile(
|
|
||||||
data.fileDescriptor,
|
|
||||||
size,
|
|
||||||
BackupRepository(
|
|
||||||
db = MangaDatabase(applicationContext),
|
|
||||||
settings = AppSettings(applicationContext),
|
|
||||||
tapGridSettings = TapGridSettings(applicationContext),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
destination.delete()
|
|
||||||
} else {
|
|
||||||
super.onRestoreFile(data, size, destination, type, mode, mtime)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@VisibleForTesting
|
|
||||||
fun createBackupFile(context: Context, repository: BackupRepository) = runBlocking {
|
|
||||||
BackupZipOutput.createTemp(context).use { backup ->
|
|
||||||
backup.put(repository.createIndex())
|
|
||||||
backup.put(repository.dumpHistory())
|
|
||||||
backup.put(repository.dumpCategories())
|
|
||||||
backup.put(repository.dumpFavourites())
|
|
||||||
backup.put(repository.dumpBookmarks())
|
|
||||||
backup.put(repository.dumpSources())
|
|
||||||
backup.put(repository.dumpSettings())
|
|
||||||
backup.put(repository.dumpReaderGridSettings())
|
|
||||||
backup.finish()
|
|
||||||
backup.file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@VisibleForTesting
|
|
||||||
fun restoreBackupFile(fd: FileDescriptor, size: Long, repository: BackupRepository) {
|
|
||||||
val tempFile = File.createTempFile("backup_", ".tmp")
|
|
||||||
FileInputStream(fd).use { input ->
|
|
||||||
tempFile.outputStream().use { output ->
|
|
||||||
input.copyLimitedTo(output, size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val backup = try {
|
|
||||||
BackupZipInput.from(tempFile)
|
|
||||||
} catch (e: BadBackupFormatException) {
|
|
||||||
tempFile.delete()
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
runBlocking {
|
|
||||||
backup.getEntry(BackupEntry.Name.HISTORY)?.let { repository.restoreHistory(it, null) }
|
|
||||||
backup.getEntry(BackupEntry.Name.CATEGORIES)?.let { repository.restoreCategories(it) }
|
|
||||||
backup.getEntry(BackupEntry.Name.FAVOURITES)?.let { repository.restoreFavourites(it, null) }
|
|
||||||
backup.getEntry(BackupEntry.Name.BOOKMARKS)?.let { repository.restoreBookmarks(it) }
|
|
||||||
backup.getEntry(BackupEntry.Name.SOURCES)?.let { repository.restoreSources(it) }
|
|
||||||
backup.getEntry(BackupEntry.Name.SETTINGS)?.let { repository.restoreSettings(it) }
|
|
||||||
backup.getEntry(BackupEntry.Name.SETTINGS_READER_GRID)?.let { repository.restoreReaderGridSettings(it) }
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
backup.close()
|
|
||||||
tempFile.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun InputStream.copyLimitedTo(out: OutputStream, limit: Long) {
|
|
||||||
var bytesCopied: Long = 0
|
|
||||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE.coerceAtMost(limit.toInt()))
|
|
||||||
var bytes = read(buffer)
|
|
||||||
while (bytes >= 0) {
|
|
||||||
out.write(buffer, 0, bytes)
|
|
||||||
bytesCopied += bytes
|
|
||||||
val bytesLeft = (limit - bytesCopied).toInt()
|
|
||||||
if (bytesLeft <= 0) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
bytes = read(buffer, 0, buffer.size.coerceAtMost(bytesLeft))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.settings.backup
|
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.backup.BackupEntry
|
|
||||||
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
|
|
||||||
import org.koitharu.kotatsu.list.ui.model.ListModel
|
|
||||||
|
|
||||||
data class BackupEntryModel(
|
|
||||||
val name: BackupEntry.Name,
|
|
||||||
val isChecked: Boolean,
|
|
||||||
val isEnabled: Boolean,
|
|
||||||
) : ListModel {
|
|
||||||
|
|
||||||
@get:StringRes
|
|
||||||
val titleResId: Int
|
|
||||||
get() = when (name) {
|
|
||||||
BackupEntry.Name.INDEX -> 0 // should not appear here
|
|
||||||
BackupEntry.Name.HISTORY -> R.string.history
|
|
||||||
BackupEntry.Name.CATEGORIES -> R.string.favourites_categories
|
|
||||||
BackupEntry.Name.FAVOURITES -> R.string.favourites
|
|
||||||
BackupEntry.Name.SETTINGS -> R.string.settings
|
|
||||||
BackupEntry.Name.SETTINGS_READER_GRID -> R.string.reader_actions
|
|
||||||
BackupEntry.Name.BOOKMARKS -> R.string.bookmarks
|
|
||||||
BackupEntry.Name.SOURCES -> R.string.remote_sources
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun areItemsTheSame(other: ListModel): Boolean {
|
|
||||||
return other is BackupEntryModel && other.name == name
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getChangePayload(previousState: ListModel): Any? {
|
|
||||||
if (previousState !is BackupEntryModel) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return if (previousState.isEnabled != isEnabled) {
|
|
||||||
ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED
|
|
||||||
} else if (previousState.isChecked != isChecked) {
|
|
||||||
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
|
|
||||||
} else {
|
|
||||||
super.getChangePayload(previousState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.settings.backup
|
|
||||||
|
|
||||||
import android.content.ContentResolver
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import okio.FileNotFoundException
|
|
||||||
import org.koitharu.kotatsu.core.backup.BackupRepository
|
|
||||||
import org.koitharu.kotatsu.core.backup.BackupZipOutput
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.call
|
|
||||||
import org.koitharu.kotatsu.core.util.progress.Progress
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class BackupViewModel @Inject constructor(
|
|
||||||
private val repository: BackupRepository,
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
) : BaseViewModel() {
|
|
||||||
|
|
||||||
val progress = MutableStateFlow(Progress.INDETERMINATE)
|
|
||||||
val onBackupDone = MutableEventFlow<File>()
|
|
||||||
val onBackupSaved = MutableEventFlow<Unit>()
|
|
||||||
|
|
||||||
private val contentResolver: ContentResolver = context.contentResolver
|
|
||||||
private var backupFile: File? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
launchLoadingJob(Dispatchers.Default) {
|
|
||||||
val file = BackupZipOutput.createTemp(context).use { backup ->
|
|
||||||
progress.value = Progress(0, 7)
|
|
||||||
backup.put(repository.createIndex())
|
|
||||||
|
|
||||||
backup.put(repository.dumpHistory())
|
|
||||||
progress.value = progress.value.inc()
|
|
||||||
|
|
||||||
backup.put(repository.dumpCategories())
|
|
||||||
progress.value = progress.value.inc()
|
|
||||||
|
|
||||||
backup.put(repository.dumpFavourites())
|
|
||||||
progress.value = progress.value.inc()
|
|
||||||
|
|
||||||
backup.put(repository.dumpBookmarks())
|
|
||||||
progress.value = progress.value.inc()
|
|
||||||
|
|
||||||
backup.put(repository.dumpSources())
|
|
||||||
progress.value = progress.value.inc()
|
|
||||||
|
|
||||||
backup.put(repository.dumpSettings())
|
|
||||||
progress.value = progress.value.inc()
|
|
||||||
|
|
||||||
backup.put(repository.dumpReaderGridSettings())
|
|
||||||
progress.value = progress.value.inc()
|
|
||||||
|
|
||||||
backup.finish()
|
|
||||||
backup.file
|
|
||||||
}
|
|
||||||
backupFile = file
|
|
||||||
onBackupDone.call(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveBackup(output: Uri) {
|
|
||||||
launchLoadingJob(Dispatchers.Default) {
|
|
||||||
val file = backupFile ?: throw FileNotFoundException()
|
|
||||||
contentResolver.openFileDescriptor(output, "w")?.use { fd ->
|
|
||||||
FileOutputStream(fd.fileDescriptor).use {
|
|
||||||
it.write(file.readBytes())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onBackupSaved.call(Unit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,300 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.settings.backup
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.app.Notification
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.ServiceInfo
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.core.app.NotificationChannelCompat
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import androidx.core.app.PendingIntentCompat
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.flow
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import org.koitharu.kotatsu.R
|
|
||||||
import org.koitharu.kotatsu.core.ErrorReporterReceiver
|
|
||||||
import org.koitharu.kotatsu.core.backup.BackupEntry
|
|
||||||
import org.koitharu.kotatsu.core.backup.BackupRepository
|
|
||||||
import org.koitharu.kotatsu.core.backup.BackupZipInput
|
|
||||||
import org.koitharu.kotatsu.core.backup.CompositeResult
|
|
||||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
|
||||||
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.getFileDisplayName
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.powerManager
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock
|
|
||||||
import org.koitharu.kotatsu.core.util.progress.Progress
|
|
||||||
import org.koitharu.kotatsu.parsers.util.mapToArray
|
|
||||||
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileNotFoundException
|
|
||||||
import java.util.EnumSet
|
|
||||||
import javax.inject.Inject
|
|
||||||
import androidx.appcompat.R as appcompatR
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class RestoreService : CoroutineIntentService() {
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
lateinit var repository: BackupRepository
|
|
||||||
|
|
||||||
private lateinit var notificationManager: NotificationManagerCompat
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
notificationManager = NotificationManagerCompat.from(applicationContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun IntentJobContext.processIntent(intent: Intent) {
|
|
||||||
startForeground(this)
|
|
||||||
val uri = intent.getStringExtra(AppRouter.KEY_DATA)?.toUriOrNull() ?: throw FileNotFoundException()
|
|
||||||
val displayName = contentResolver.getFileDisplayName(uri)
|
|
||||||
val entries = intent.getIntArrayExtra(AppRouter.KEY_ENTRIES)
|
|
||||||
?.mapTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { BackupEntry.Name.entries[it] }
|
|
||||||
if (entries.isNullOrEmpty()) {
|
|
||||||
throw IllegalArgumentException("No entries specified")
|
|
||||||
}
|
|
||||||
powerManager.withPartialWakeLock(TAG) {
|
|
||||||
val result = runInterruptible(Dispatchers.IO) {
|
|
||||||
val tempFile = File.createTempFile("backup_", ".tmp")
|
|
||||||
(contentResolver.openInputStream(uri) ?: throw FileNotFoundException()).use { input ->
|
|
||||||
tempFile.outputStream().use { output ->
|
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BackupZipInput.from(tempFile)
|
|
||||||
}.use { backupInput ->
|
|
||||||
restoreImpl(displayName, backupInput, entries)
|
|
||||||
}
|
|
||||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
|
||||||
val notification = buildNotification(displayName, result)
|
|
||||||
notificationManager.notify(TAG, startId, notification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun IntentJobContext.onError(error: Throwable) {
|
|
||||||
if (applicationContext.checkNotificationPermission(CHANNEL_ID)) {
|
|
||||||
val result = CompositeResult()
|
|
||||||
result += error
|
|
||||||
val notification = buildNotification(null, result)
|
|
||||||
notificationManager.notify(TAG, startId, notification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun IntentJobContext.restoreImpl(
|
|
||||||
displayName: String?,
|
|
||||||
input: BackupZipInput,
|
|
||||||
entries: Set<BackupEntry.Name>
|
|
||||||
): CompositeResult {
|
|
||||||
val result = CompositeResult()
|
|
||||||
val showNotification = applicationContext.checkNotificationPermission(CHANNEL_ID)
|
|
||||||
var progress = Progress(0, entries.size)
|
|
||||||
|
|
||||||
fun notify(childProgress: Progress? = null) {
|
|
||||||
if (showNotification) {
|
|
||||||
val p = childProgress?.let { progress + it } ?: progress
|
|
||||||
notificationManager.notify(FOREGROUND_NOTIFICATION_ID, buildNotification(displayName, p))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
notify()
|
|
||||||
|
|
||||||
if (BackupEntry.Name.HISTORY in entries) {
|
|
||||||
input.getEntry(BackupEntry.Name.HISTORY)?.let {
|
|
||||||
flow {
|
|
||||||
result += repository.restoreHistory(it, this)
|
|
||||||
}.collect { p ->
|
|
||||||
notify(p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
progress++
|
|
||||||
}
|
|
||||||
|
|
||||||
notify()
|
|
||||||
|
|
||||||
if (BackupEntry.Name.CATEGORIES in entries) {
|
|
||||||
input.getEntry(BackupEntry.Name.CATEGORIES)?.let {
|
|
||||||
result += repository.restoreCategories(it)
|
|
||||||
}
|
|
||||||
progress++
|
|
||||||
}
|
|
||||||
|
|
||||||
notify()
|
|
||||||
|
|
||||||
if (BackupEntry.Name.FAVOURITES in entries) {
|
|
||||||
input.getEntry(BackupEntry.Name.FAVOURITES)?.let {
|
|
||||||
flow {
|
|
||||||
result += repository.restoreFavourites(it, this)
|
|
||||||
}.collect { p ->
|
|
||||||
notify(p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
notify()
|
|
||||||
|
|
||||||
if (BackupEntry.Name.BOOKMARKS in entries) {
|
|
||||||
input.getEntry(BackupEntry.Name.BOOKMARKS)?.let {
|
|
||||||
result += repository.restoreBookmarks(it)
|
|
||||||
}
|
|
||||||
progress++
|
|
||||||
}
|
|
||||||
|
|
||||||
notify()
|
|
||||||
|
|
||||||
if (BackupEntry.Name.SOURCES in entries) {
|
|
||||||
input.getEntry(BackupEntry.Name.SOURCES)?.let {
|
|
||||||
result += repository.restoreSources(it)
|
|
||||||
}
|
|
||||||
progress++
|
|
||||||
}
|
|
||||||
|
|
||||||
notify()
|
|
||||||
|
|
||||||
if (BackupEntry.Name.SETTINGS in entries) {
|
|
||||||
input.getEntry(BackupEntry.Name.SETTINGS)?.let {
|
|
||||||
result += repository.restoreSettings(it)
|
|
||||||
}
|
|
||||||
progress++
|
|
||||||
}
|
|
||||||
|
|
||||||
notify()
|
|
||||||
|
|
||||||
if (BackupEntry.Name.SETTINGS_READER_GRID in entries) {
|
|
||||||
input.getEntry(BackupEntry.Name.SETTINGS_READER_GRID)?.let {
|
|
||||||
result += repository.restoreReaderGridSettings(it)
|
|
||||||
}
|
|
||||||
progress++
|
|
||||||
}
|
|
||||||
|
|
||||||
notify()
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("InlinedApi")
|
|
||||||
private fun startForeground(jobContext: IntentJobContext) {
|
|
||||||
val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
|
|
||||||
.setName(getString(R.string.restoring_backup))
|
|
||||||
.setShowBadge(true)
|
|
||||||
.setVibrationEnabled(false)
|
|
||||||
.setSound(null, null)
|
|
||||||
.setLightsEnabled(false)
|
|
||||||
.build()
|
|
||||||
notificationManager.createNotificationChannel(channel)
|
|
||||||
|
|
||||||
val notification = jobContext.buildNotification(null, null)
|
|
||||||
|
|
||||||
jobContext.setForeground(
|
|
||||||
FOREGROUND_NOTIFICATION_ID,
|
|
||||||
notification,
|
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun IntentJobContext.buildNotification(fileName: String?, progress: Progress?): Notification {
|
|
||||||
return NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
|
||||||
.setContentTitle(getString(R.string.restoring_backup))
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
||||||
.setDefaults(0)
|
|
||||||
.setSilent(true)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setProgress(progress?.total ?: 0, progress?.progress ?: 0, progress == null)
|
|
||||||
.setContentText(
|
|
||||||
concatStrings(
|
|
||||||
context = this@RestoreService,
|
|
||||||
a = fileName,
|
|
||||||
b = progress?.run { getString(R.string.percent_string_pattern, percentSting()) },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
|
||||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
|
||||||
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
|
|
||||||
.addAction(
|
|
||||||
appcompatR.drawable.abc_ic_clear_material,
|
|
||||||
applicationContext.getString(android.R.string.cancel),
|
|
||||||
getCancelIntent(),
|
|
||||||
).build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildNotification(fileName: String?, result: CompositeResult): Notification {
|
|
||||||
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
|
||||||
.setDefaults(0)
|
|
||||||
.setSilent(true)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.setSubText(fileName)
|
|
||||||
|
|
||||||
when {
|
|
||||||
result.isEmpty -> notification.setContentTitle(getString(R.string.data_not_restored))
|
|
||||||
.setContentText(getString(R.string.data_not_restored_text))
|
|
||||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
|
||||||
|
|
||||||
result.isAllSuccess -> notification.setContentTitle(getString(R.string.data_restored))
|
|
||||||
.setContentText(getString(R.string.data_restored_success))
|
|
||||||
.setSmallIcon(R.drawable.ic_stat_done)
|
|
||||||
|
|
||||||
result.isAllFailed -> notification.setContentTitle(getString(R.string.error))
|
|
||||||
.setContentText(
|
|
||||||
result.failures.map { it.getDisplayMessage(resources) }.distinct().joinToString("\n"),
|
|
||||||
)
|
|
||||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
|
||||||
|
|
||||||
else -> notification.setContentTitle(getString(R.string.data_restored))
|
|
||||||
.setContentText(getString(R.string.data_restored_with_errors))
|
|
||||||
.setSmallIcon(R.drawable.ic_stat_done)
|
|
||||||
}
|
|
||||||
result.failures.firstOrNull()?.let { error ->
|
|
||||||
ErrorReporterReceiver.getPendingIntent(applicationContext, error)?.let { reportIntent ->
|
|
||||||
notification.addAction(
|
|
||||||
R.drawable.ic_alert_outline,
|
|
||||||
applicationContext.getString(R.string.report),
|
|
||||||
reportIntent,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
notification.setContentIntent(
|
|
||||||
PendingIntentCompat.getActivity(
|
|
||||||
applicationContext,
|
|
||||||
0,
|
|
||||||
AppRouter.homeIntent(this),
|
|
||||||
0,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return notification.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun concatStrings(context: Context, a: String?, b: String?): String? = when {
|
|
||||||
a.isNullOrEmpty() && b.isNullOrEmpty() -> null
|
|
||||||
a.isNullOrEmpty() -> b?.nullIfEmpty()
|
|
||||||
b.isNullOrEmpty() -> a.nullIfEmpty()
|
|
||||||
else -> context.getString(R.string.download_summary_pattern, a, b)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
|
|
||||||
private const val TAG = "restore"
|
|
||||||
private const val CHANNEL_ID = "restore_backup"
|
|
||||||
private const val FOREGROUND_NOTIFICATION_ID = 39
|
|
||||||
|
|
||||||
fun start(context: Context, uri: Uri, entries: Set<BackupEntry.Name>): Boolean = try {
|
|
||||||
val intent = Intent(context, RestoreService::class.java)
|
|
||||||
intent.putExtra(AppRouter.KEY_DATA, uri.toString())
|
|
||||||
intent.putExtra(AppRouter.KEY_ENTRIES, entries.mapToArray { it.ordinal }.toIntArray())
|
|
||||||
ContextCompat.startForegroundService(context, intent)
|
|
||||||
true
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTraceDebug()
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.settings.backup
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.runInterruptible
|
|
||||||
import org.koitharu.kotatsu.core.backup.BackupEntry
|
|
||||||
import org.koitharu.kotatsu.core.backup.BackupRepository
|
|
||||||
import org.koitharu.kotatsu.core.backup.BackupZipInput
|
|
||||||
import org.koitharu.kotatsu.core.nav.AppRouter
|
|
||||||
import org.koitharu.kotatsu.core.ui.BaseViewModel
|
|
||||||
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
|
|
||||||
import java.io.File
|
|
||||||
import java.io.FileNotFoundException
|
|
||||||
import java.util.Date
|
|
||||||
import java.util.EnumMap
|
|
||||||
import java.util.EnumSet
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class RestoreViewModel @Inject constructor(
|
|
||||||
savedStateHandle: SavedStateHandle,
|
|
||||||
private val repository: BackupRepository,
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
) : BaseViewModel() {
|
|
||||||
|
|
||||||
val uri = savedStateHandle.get<String>(AppRouter.KEY_FILE)?.toUriOrNull()
|
|
||||||
private val contentResolver = context.contentResolver
|
|
||||||
|
|
||||||
val availableEntries = MutableStateFlow<List<BackupEntryModel>>(emptyList())
|
|
||||||
val backupDate = MutableStateFlow<Date?>(null)
|
|
||||||
|
|
||||||
init {
|
|
||||||
launchLoadingJob(Dispatchers.Default) {
|
|
||||||
loadBackupInfo()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loadBackupInfo() {
|
|
||||||
runInterruptible(Dispatchers.IO) {
|
|
||||||
val tempFile = File.createTempFile("backup_", ".tmp")
|
|
||||||
(uri?.let { contentResolver.openInputStream(it) } ?: throw FileNotFoundException()).use { input ->
|
|
||||||
tempFile.outputStream().use { output ->
|
|
||||||
input.copyTo(output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BackupZipInput.from(tempFile)
|
|
||||||
}.use { backup ->
|
|
||||||
val entries = backup.entries()
|
|
||||||
availableEntries.value = BackupEntry.Name.entries.mapNotNull { entry ->
|
|
||||||
if (entry == BackupEntry.Name.INDEX || entry !in entries) {
|
|
||||||
return@mapNotNull null
|
|
||||||
}
|
|
||||||
BackupEntryModel(
|
|
||||||
name = entry,
|
|
||||||
isChecked = true,
|
|
||||||
isEnabled = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
backupDate.value = repository.getBackupDate(backup.getEntry(BackupEntry.Name.INDEX))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onItemClick(item: BackupEntryModel) {
|
|
||||||
val map = availableEntries.value.associateByTo(EnumMap(BackupEntry.Name::class.java)) { it.name }
|
|
||||||
map[item.name] = item.copy(isChecked = !item.isChecked)
|
|
||||||
map.validate()
|
|
||||||
availableEntries.value = map.values.sortedBy { it.name.ordinal }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCheckedEntries(): Set<BackupEntry.Name> = availableEntries.value
|
|
||||||
.mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) {
|
|
||||||
if (it.isChecked) it.name else null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check for inconsistent user selection
|
|
||||||
* Favorites cannot be restored without categories
|
|
||||||
*/
|
|
||||||
private fun MutableMap<BackupEntry.Name, BackupEntryModel>.validate() {
|
|
||||||
val favorites = this[BackupEntry.Name.FAVOURITES] ?: return
|
|
||||||
val categories = this[BackupEntry.Name.CATEGORIES]
|
|
||||||
if (categories?.isChecked == true) {
|
|
||||||
if (!favorites.isEnabled) {
|
|
||||||
this[BackupEntry.Name.FAVOURITES] = favorites.copy(isEnabled = true)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (favorites.isEnabled) {
|
|
||||||
this[BackupEntry.Name.FAVOURITES] = favorites.copy(isEnabled = false, isChecked = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
package org.koitharu.kotatsu.core.backup
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Test
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.MangaEntity
|
|
||||||
import org.koitharu.kotatsu.core.db.entity.TagEntity
|
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
|
|
||||||
import org.koitharu.kotatsu.favourites.data.FavouriteEntity
|
|
||||||
import org.koitharu.kotatsu.history.data.HistoryEntity
|
|
||||||
import org.koitharu.kotatsu.parsers.model.ContentRating
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaParserSource
|
|
||||||
import org.koitharu.kotatsu.parsers.model.MangaState
|
|
||||||
import org.koitharu.kotatsu.parsers.model.SortOrder
|
|
||||||
import java.util.concurrent.TimeUnit
|
|
||||||
|
|
||||||
class JsonSerializerTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun toFavouriteEntity() {
|
|
||||||
val entity = FavouriteEntity(
|
|
||||||
mangaId = 40,
|
|
||||||
categoryId = 20,
|
|
||||||
sortKey = 1,
|
|
||||||
createdAt = System.currentTimeMillis(),
|
|
||||||
deletedAt = 0L,
|
|
||||||
)
|
|
||||||
val json = JsonSerializer(entity).toJson()
|
|
||||||
val result = JsonDeserializer(json).toFavouriteEntity()
|
|
||||||
assertEquals(entity, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun toMangaEntity() {
|
|
||||||
val entity = MangaEntity(
|
|
||||||
id = 231,
|
|
||||||
title = "Lorem Ipsum",
|
|
||||||
altTitles = "Lorem Ispum 2",
|
|
||||||
url = "erw",
|
|
||||||
publicUrl = "hthth",
|
|
||||||
rating = 0.78f,
|
|
||||||
isNsfw = true,
|
|
||||||
contentRating = ContentRating.ADULT.name,
|
|
||||||
coverUrl = "5345",
|
|
||||||
largeCoverUrl = null,
|
|
||||||
state = MangaState.FINISHED.name,
|
|
||||||
authors = "RERE",
|
|
||||||
source = MangaParserSource.DUMMY.name,
|
|
||||||
)
|
|
||||||
val json = JsonSerializer(entity).toJson()
|
|
||||||
val result = JsonDeserializer(json).toMangaEntity()
|
|
||||||
assertEquals(entity, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun toTagEntity() {
|
|
||||||
val entity = TagEntity(
|
|
||||||
id = 934023534,
|
|
||||||
title = "Adventure",
|
|
||||||
key = "adventure",
|
|
||||||
source = MangaParserSource.DUMMY.name,
|
|
||||||
)
|
|
||||||
val json = JsonSerializer(entity).toJson()
|
|
||||||
val result = JsonDeserializer(json).toTagEntity()
|
|
||||||
assertEquals(entity, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun toHistoryEntity() {
|
|
||||||
val entity = HistoryEntity(
|
|
||||||
mangaId = 304135341,
|
|
||||||
createdAt = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(6),
|
|
||||||
updatedAt = System.currentTimeMillis(),
|
|
||||||
chapterId = 29014843034,
|
|
||||||
page = 35,
|
|
||||||
scroll = 24.0f,
|
|
||||||
percent = 0.6f,
|
|
||||||
deletedAt = 0L,
|
|
||||||
chaptersCount = 12,
|
|
||||||
)
|
|
||||||
val json = JsonSerializer(entity).toJson()
|
|
||||||
val result = JsonDeserializer(json).toHistoryEntity()
|
|
||||||
assertEquals(entity, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun toFavouriteCategoryEntity() {
|
|
||||||
val entity = FavouriteCategoryEntity(
|
|
||||||
categoryId = 142,
|
|
||||||
createdAt = System.currentTimeMillis(),
|
|
||||||
sortKey = 14,
|
|
||||||
title = "Read later",
|
|
||||||
order = SortOrder.RATING.name,
|
|
||||||
track = false,
|
|
||||||
isVisibleInLibrary = true,
|
|
||||||
deletedAt = 0L,
|
|
||||||
)
|
|
||||||
val json = JsonSerializer(entity).toJson()
|
|
||||||
val result = JsonDeserializer(json).toFavouriteCategoryEntity()
|
|
||||||
assertEquals(entity, result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue