Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.core.net.toUri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import dev.zacsweers.metro.AppScope
Expand All @@ -41,6 +42,7 @@ import eu.kanade.tachiyomi.util.system.workManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.LabeledCheckbox
import tachiyomi.presentation.core.components.LazyColumnWithAction
Expand Down Expand Up @@ -191,7 +193,9 @@ class RestoreBackupViewModel(
}

init {
validate(uri.toUri())
viewModelScope.launchIO {
validate(uri.toUri())
}
}

fun toggle(setter: (RestoreOptions, Boolean) -> RestoreOptions, enabled: Boolean) {
Expand All @@ -210,7 +214,7 @@ class RestoreBackupViewModel(
)
}

private fun validate(uri: Uri) {
private suspend fun validate(uri: Uri) {
val results = try {
backupFileValidator.validate(uri)
} catch (e: Exception) {
Expand Down
106 changes: 86 additions & 20 deletions app/src/main/java/eu/kanade/tachiyomi/data/backup/BackupDecoder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@ import android.content.Context
import android.net.Uri
import dev.zacsweers.metro.Inject
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.serialization.SerializationException
import kotlinx.serialization.protobuf.ProtoBuf
import okio.Buffer
import okio.BufferedSource
import okio.buffer
import okio.gzip
import okio.source
Expand All @@ -18,31 +23,92 @@ class BackupDecoder(
private val context: Context,
private val parser: ProtoBuf,
) {
/**
* Decode a potentially-gzipped backup.
*/
fun decode(uri: Uri): Backup {
return context.contentResolver.openInputStream(uri)!!.use { inputStream ->
val source = inputStream.source().buffer()

val peeked = source.peek().apply {
require(2)
fun decodeMetadata(uri: Uri): Pair<Int, Backup> {
context.contentResolver.openInputStream(uri)!!.use { inputStream ->
val source = inputStream.source().buffer().prepareBackupSource(context)

var mangaCount = 0
while (!source.exhausted()) {
val tag = source.readByte().toInt() and 0xFF
if (tag == 0x0A) {
mangaCount++
val length = source.readVarInt().toLong()
source.skip(length)
} else {
val tagBuffer = Buffer().writeByte(tag)
tagBuffer.writeAll(source)

val backup = try {
parser.decodeFromByteArray(Backup.serializer(), tagBuffer.readByteArray())
} catch (_: SerializationException) {
throw IOException(context.stringResource(MR.strings.invalid_backup_file_unknown))
}

return Pair(mangaCount, backup)
}
}
val id1id2 = peeked.readShort()
val backupString = when (id1id2.toInt()) {
0x1f8b -> source.gzip().buffer() // 0x1f8b is gzip magic bytes
MAGIC_JSON_SIGNATURE1, MAGIC_JSON_SIGNATURE2, MAGIC_JSON_SIGNATURE3 -> {
throw IOException(context.stringResource(MR.strings.invalid_backup_file_json))
return Pair(mangaCount, Backup(emptyList()))
}
}

fun decodeManga(uri: Uri): Flow<BackupManga> = flow {
context.contentResolver.openInputStream(uri)!!.use { inputStream ->
val source = inputStream.source().buffer().prepareBackupSource(context)

while (!source.exhausted()) {
val tag = source.readByte().toInt() and 0xFF
if (tag == 0x0A) {
val length = source.readVarInt().toLong()
val bytes = source.readByteArray(length)

val manga = try {
parser.decodeFromByteArray(BackupManga.serializer(), bytes)
} catch (_: SerializationException) {
throw IOException(context.stringResource(MR.strings.invalid_backup_file_unknown))
}

emit(manga)
} else {
break
}
else -> source
}.use { it.readByteArray() }
}
}
}

private fun BufferedSource.readVarInt(): Int {
var result = 0
var shift = 0

while (shift < 32) {
val b = readByte().toInt() and 0xFF
result = result or ((b and 0x7F) shl shift)

if ((b and 0x80) == 0) {
return result
}

shift += 7
}

throw IOException(context.stringResource(MR.strings.invalid_backup_file_json))
}

try {
parser.decodeFromByteArray(Backup.serializer(), backupString)
} catch (_: SerializationException) {
throw IOException(context.stringResource(MR.strings.invalid_backup_file_unknown))
private fun BufferedSource.prepareBackupSource(context: Context): BufferedSource {
val source = if (peek().request(2) && peek().readShort().toInt() == 0x1f8b) {
gzip().buffer()
} else {
this
}

val peeked = source.peek()
if (peeked.request(2)) {
val id1id2 = peeked.readShort().toInt()
if (id1id2 == MAGIC_JSON_SIGNATURE1 || id1id2 == MAGIC_JSON_SIGNATURE2 || id1id2 == MAGIC_JSON_SIGNATURE3) {
throw IOException(context.stringResource(MR.strings.invalid_backup_file_json))
}
}

return source
}

companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,9 @@ class BackupFileValidator(
*
* @return List of missing sources or missing trackers.
*/
fun validate(uri: Uri): Results {
val backup = try {
backupDecoder.decode(uri)
} catch (e: Exception) {
throw IllegalStateException(e)
}
suspend fun validate(uri: Uri): Results {
val backupMangaFlow = backupDecoder.decodeManga(uri)
val (_, backup) = backupDecoder.decodeMetadata(uri)

val sources = backup.backupSources.associate { it.sourceId to it.name }
val missingSources = sources
Expand All @@ -38,12 +35,12 @@ class BackupFileValidator(
.distinct()
.sorted()

val trackers = backup.backupManga
.flatMap { it.tracking }
.map { it.syncId }
.distinct()
val trackers = mutableSetOf<Long>()
backupMangaFlow.collect { manga ->
manga.tracking.forEach { trackers += it.syncId.toLong() }
}
val missingTrackers = trackers
.mapNotNull { trackerManager.get(it.toLong()) }
.mapNotNull(trackerManager::get)
.filter { !it.isLoggedIn }
.map { it.name }
.sorted()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,12 @@ import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSource
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.chunked
import kotlinx.coroutines.flow.flowOf
import kotlinx.serialization.protobuf.ProtoBuf
import logcat.LogPriority
import okio.BufferedSink
import okio.buffer
import okio.gzip
import okio.sink
Expand Down Expand Up @@ -83,29 +87,28 @@ class BackupCreator(
}

val nonFavoriteManga = if (options.readEntries) mangaRepository.getReadMangaNotInLibrary() else emptyList()
val backupManga = backupMangas(getFavorites.await() + nonFavoriteManga, options)

val backup = Backup(
backupManga = backupManga,
backupCategories = backupCategories(options),
backupSources = backupSources(backupManga),
backupPreferences = backupAppPreferences(options),
backupExtensionStores = backupExtensionStores(options),
backupSourcePreferences = backupSourcePreferences(options),
)

val byteArray = parser.encodeToByteArray(Backup.serializer(), backup)
if (byteArray.isEmpty()) {
throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error))
}
val mangas = getFavorites.await() + nonFavoriteManga
val backupMangaFlow = backupMangas(mangas, options)

file.openOutputStream()
.also {
// Force overwrite old file
(it as? FileOutputStream)?.channel?.truncate(0)
}
.sink().gzip().buffer().use {
it.write(byteArray)
.sink().gzip().buffer().use { sink ->
val success = writeBackupToSink(
mangaFlow = backupMangaFlow,
categories = backupCategories(options),
sources = backupSources(mangas),
preferences = backupAppPreferences(options),
extensionStores = backupExtensionStores(options),
sourcePreferences = backupSourcePreferences(options),
sink = sink,
)

if (!success) {
throw IllegalStateException(context.stringResource(MR.strings.empty_backup_error))
}
}
val fileUri = file.uri

Expand All @@ -130,13 +133,13 @@ class BackupCreator(
return categoriesBackupCreator()
}

private suspend fun backupMangas(mangas: List<Manga>, options: BackupOptions): List<BackupManga> {
if (!options.libraryEntries) return emptyList()
private fun backupMangas(mangas: List<Manga>, options: BackupOptions): Flow<BackupManga> {
if (!options.libraryEntries) return flowOf()

return mangaBackupCreator(mangas, options)
}

private fun backupSources(mangas: List<BackupManga>): List<BackupSource> {
private fun backupSources(mangas: List<Manga>): List<BackupSource> {
return sourcesBackupCreator(mangas)
}

Expand All @@ -158,6 +161,57 @@ class BackupCreator(
return preferenceBackupCreator.createSource(includePrivatePreferences = options.privateSettings)
}

private suspend fun writeBackupToSink(
mangaFlow: Flow<BackupManga>,
categories: List<BackupCategory>,
sources: List<BackupSource>,
preferences: List<BackupPreference>,
sourcePreferences: List<BackupSourcePreferences>,
extensionStores: List<BackupExtensionStore>,
sink: BufferedSink,
): Boolean {
var emptyMangas = true

val tempBuffer = okio.Buffer()

mangaFlow.chunked(100).collect { chunk ->
emptyMangas = false
for (manga in chunk) {
val mangaBytes = parser.encodeToByteArray(BackupManga.serializer(), manga)
// Protobuf Tag for field 1, wire type 2 (Length-delimited): (1 << 3) | 2
tempBuffer.writeByte(0x0A)
tempBuffer.writeVarInt(mangaBytes.size)
tempBuffer.write(mangaBytes)
}

sink.write(tempBuffer, tempBuffer.size)
}

val remaining = parser.encodeToByteArray(
Backup.serializer(),
Backup(
backupManga = emptyList(),
backupCategories = categories,
backupSources = sources,
backupPreferences = preferences,
backupSourcePreferences = sourcePreferences,
backupExtensionStores = extensionStores,
),
)
sink.write(remaining)

return !emptyMangas || remaining.isNotEmpty()
}

private fun BufferedSink.writeVarInt(value: Int) {
var v = value
while ((v and 0x7F.inv()) != 0) {
writeByte(((v and 0x7F) or 0x80))
v = v ushr 7
}
writeByte(v)
}

companion object {
private const val MAX_AUTO_BACKUPS: Int = 4
private val FILENAME_REGEX = """${BuildConfig.APPLICATION_ID}_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}.tachibk""".toRegex()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.backupChapterMapper
import eu.kanade.tachiyomi.data.backup.models.backupTrackMapper
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.map
import tachiyomi.data.Database
import tachiyomi.data.MemoColumnAdapter
import tachiyomi.domain.category.interactor.GetCategories
Expand All @@ -23,8 +26,8 @@ class MangaBackupCreator(
private val getHistory: GetHistory,
) {

suspend operator fun invoke(mangas: List<Manga>, options: BackupOptions): List<BackupManga> {
return mangas.map {
operator fun invoke(mangas: List<Manga>, options: BackupOptions): Flow<BackupManga> {
return mangas.asFlow().map {
backupManga(it, options)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
package eu.kanade.tachiyomi.data.backup.create.creators

import dev.zacsweers.metro.Inject
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupSource
import eu.kanade.tachiyomi.source.Source
import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.source.service.SourceManager

@Inject
class SourcesBackupCreator(
private val sourceManager: SourceManager,
) {

operator fun invoke(mangas: List<BackupManga>): List<BackupSource> {
operator fun invoke(mangas: List<Manga>): List<BackupSource> {
return mangas
.asSequence()
.map(BackupManga::source)
.map(Manga::source)
.distinct()
.map(sourceManager::getOrStub)
.map { it.toBackupSource() }
Expand Down
Loading