Restmore may be broken
This commit is contained in:
Syer10
2024-09-21 13:42:22 -04:00
parent 478c58a9ac
commit a4a828dc62
19 changed files with 278 additions and 80 deletions

View File

@@ -135,7 +135,6 @@ android {
"META-INF/README.md",
"META-INF/NOTICE",
"META-INF/*.kotlin_module",
"META-INF/*.version",
))
}
}

View File

@@ -65,11 +65,11 @@ subprojects {
}
tasks.withType<org.jmailen.gradle.kotlinter.tasks.LintTask> {
source(files("src"))
exclude("ca/gosyer/jui/*/build")
exclude("ca/gosyer/jui/*/build", "graphql")
}
tasks.withType<org.jmailen.gradle.kotlinter.tasks.FormatTask> {
source(files("src"))
exclude("ca/gosyer/jui/*/build")
exclude("ca/gosyer/jui/*/build", "ca/gosyer/jui/*/build")
}
plugins.withType<com.android.build.gradle.BasePlugin> {
configure<com.android.build.gradle.BaseExtension> {

View File

@@ -7,6 +7,7 @@
package ca.gosyer.jui.core.io
import ca.gosyer.jui.core.lang.withIOContext
import io.ktor.utils.io.ByteReadChannel
import okio.Buffer
import okio.BufferedSink
import okio.BufferedSource
@@ -15,6 +16,8 @@ import okio.Path
import okio.Source
import okio.buffer
import okio.use
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
suspend fun Source.saveTo(path: Path) {
withIOContext {
@@ -36,3 +39,5 @@ suspend fun Source.copyTo(sink: BufferedSink) {
}
fun ByteArray.source(): BufferedSource = Buffer().write(this)
expect suspend fun ByteReadChannel.toSource(context: CoroutineContext = EmptyCoroutineContext): Source

View File

@@ -4,7 +4,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.jui.ui.util.lang
package ca.gosyer.jui.core.io
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.cancel
@@ -17,7 +17,7 @@ import kotlin.coroutines.CoroutineContext
actual suspend fun ByteReadChannel.toSource(context: CoroutineContext): Source {
val channel = this
return object : okio.Source {
return object : Source {
override fun close() {
channel.cancel()
}

View File

@@ -0,0 +1,10 @@
package ca.gosyer.jui.core.io
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.jvm.javaio.toInputStream
import kotlinx.coroutines.Job
import okio.Source
import okio.source
import kotlin.coroutines.CoroutineContext
actual suspend fun ByteReadChannel.toSource(context: CoroutineContext): Source = toInputStream(context[Job]).source()

View File

@@ -0,0 +1,35 @@
fragment RestoreStatusFragment on BackupRestoreStatus {
state
mangaProgress
totalManga
}
query ValidateBackup($backup: Upload!) {
validateBackup(input: {backup: $backup}) {
missingSources {
id
name
}
}
}
mutation RestoreBackup($backup: Upload!) {
restoreBackup(input: {backup: $backup}) {
id
status {
...RestoreStatusFragment
}
}
}
query RestoreStatus($id: String!) {
restoreStatus(id: $id) {
...RestoreStatusFragment
}
}
mutation CreateBackup($includeCategories: Boolean!, $includeChapters: Boolean!) {
createBackup(input: {includeCategories: $includeCategories, includeChapters: $includeChapters}) {
url
}
}

View File

@@ -7,8 +7,10 @@
package ca.gosyer.jui.data
import ca.gosyer.jui.core.lang.addSuffix
import ca.gosyer.jui.data.backup.BackupRepositoryImpl
import ca.gosyer.jui.data.chapter.ChapterRepositoryImpl
import ca.gosyer.jui.data.settings.SettingsRepositoryImpl
import ca.gosyer.jui.domain.backup.service.BackupRepository
import ca.gosyer.jui.domain.chapter.service.ChapterRepository
import ca.gosyer.jui.domain.server.Http
import ca.gosyer.jui.domain.server.service.ServerPreferences
@@ -59,4 +61,11 @@ interface DataComponent : SharedDataComponent {
http: Http,
serverPreferences: ServerPreferences,
): ChapterRepository = ChapterRepositoryImpl(apolloClient, http, serverPreferences.serverUrl().get())
@Provides
fun backupRepository(
apolloClient: ApolloClient,
http: Http,
serverPreferences: ServerPreferences,
): BackupRepository = BackupRepositoryImpl(apolloClient, http, serverPreferences.serverUrl().get())
}

View File

@@ -0,0 +1,120 @@
package ca.gosyer.jui.data.backup
import ca.gosyer.jui.core.io.toSource
import ca.gosyer.jui.data.graphql.CreateBackupMutation
import ca.gosyer.jui.data.graphql.RestoreBackupMutation
import ca.gosyer.jui.data.graphql.RestoreStatusQuery
import ca.gosyer.jui.data.graphql.ValidateBackupQuery
import ca.gosyer.jui.data.graphql.fragment.RestoreStatusFragment
import ca.gosyer.jui.data.graphql.type.BackupRestoreState
import ca.gosyer.jui.domain.backup.model.BackupValidationResult
import ca.gosyer.jui.domain.backup.model.RestoreState
import ca.gosyer.jui.domain.backup.model.RestoreStatus
import ca.gosyer.jui.domain.backup.service.BackupRepository
import ca.gosyer.jui.domain.server.Http
import com.apollographql.apollo.ApolloClient
import com.apollographql.apollo.api.DefaultUpload
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.Url
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import okio.Source
import okio.buffer
class BackupRepositoryImpl(
private val apolloClient: ApolloClient,
private val http: Http,
private val serverUrl: Url,
) : BackupRepository {
override fun validateBackup(source: Source): Flow<BackupValidationResult> {
return apolloClient.query(
ValidateBackupQuery(
DefaultUpload.Builder()
.content {
it.writeAll(source.buffer())
}
.fileName("backup.tachibk")
.contentType("application/octet-stream")
.build()
)
).toFlow()
.map {
BackupValidationResult(
missingSources = it.dataAssertNoErrors.validateBackup.missingSources.map { source ->
"${source.name} (${source.id})"
},
missingTrackers = emptyList()
)
}
}
override fun restoreBackup(source: Source): Flow<Pair<String, RestoreStatus>> {
return apolloClient.mutation(
RestoreBackupMutation(
DefaultUpload.Builder()
.content {
it.writeAll(source.buffer())
}
.fileName("backup.tachibk")
.contentType("application/octet-stream")
.build()
)
).toFlow()
.map {
val data = it.dataAssertNoErrors
data.restoreBackup.id to data.restoreBackup.status!!
.restoreStatusFragment
.toRestoreStatus()
}
}
override fun restoreStatus(id: String): Flow<RestoreStatus> {
return apolloClient.query(
RestoreStatusQuery(id)
).toFlow()
.map {
val data = it.dataAssertNoErrors
data.restoreStatus!!.restoreStatusFragment.toRestoreStatus()
}
}
override fun createBackup(
includeCategories: Boolean,
includeChapters: Boolean,
block: HttpRequestBuilder.() -> Unit,
): Flow<Pair<String, Source>> {
return apolloClient
.mutation(
CreateBackupMutation(includeCategories, includeChapters)
)
.toFlow()
.map {
val url = it.dataAssertNoErrors.createBackup.url
val response = http.get(
Url("$serverUrl${url}")
)
val fileName = response.headers["content-disposition"]!!
.substringAfter("filename=")
.trim('"')
fileName to response.bodyAsChannel().toSource()
}
}
companion object {
private fun RestoreStatusFragment.toRestoreStatus() = RestoreStatus(
when (state) {
BackupRestoreState.IDLE -> RestoreState.IDLE
BackupRestoreState.SUCCESS -> RestoreState.SUCCESS
BackupRestoreState.FAILURE -> RestoreState.FAILURE
BackupRestoreState.RESTORING_CATEGORIES -> RestoreState.RESTORING_CATEGORIES
BackupRestoreState.RESTORING_MANGA -> RestoreState.RESTORING_MANGA
BackupRestoreState.UNKNOWN__ -> RestoreState.UNKNOWN
},
mangaProgress,
totalManga,
)
}
}

View File

@@ -6,7 +6,7 @@
package ca.gosyer.jui.domain.backup.interactor
import ca.gosyer.jui.domain.backup.service.BackupRepositoryOld
import ca.gosyer.jui.domain.backup.service.BackupRepository
import io.ktor.client.request.HttpRequestBuilder
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.singleOrNull
@@ -16,19 +16,25 @@ import org.lighthousegames.logging.logging
class ExportBackupFile
@Inject
constructor(
private val backupRepositoryOld: BackupRepositoryOld,
private val backupRepository: BackupRepository,
) {
suspend fun await(
includeCategories: Boolean,
includeChapters: Boolean,
block: HttpRequestBuilder.() -> Unit = {},
onError: suspend (Throwable) -> Unit = {},
) = asFlow(block)
) = asFlow(includeCategories, includeChapters, block)
.catch {
onError(it)
log.warn(it) { "Failed to export backup" }
}
.singleOrNull()
fun asFlow(block: HttpRequestBuilder.() -> Unit = {}) = backupRepositoryOld.exportBackupFile(block)
fun asFlow(
includeCategories: Boolean,
includeChapters: Boolean,
block: HttpRequestBuilder.() -> Unit = {},
) = backupRepository.createBackup(includeCategories, includeChapters, block)
companion object {
private val log = logging()

View File

@@ -6,24 +6,24 @@
package ca.gosyer.jui.domain.backup.interactor
import ca.gosyer.jui.domain.backup.service.BackupRepositoryOld
import io.ktor.client.request.HttpRequestBuilder
import ca.gosyer.jui.domain.backup.service.BackupRepository
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
import okio.FileSystem
import okio.Path
import okio.SYSTEM
import org.lighthousegames.logging.logging
class ImportBackupFile
@Inject
constructor(
private val backupRepositoryOld: BackupRepositoryOld,
private val backupRepository: BackupRepository,
) {
suspend fun await(
file: Path,
block: HttpRequestBuilder.() -> Unit = {},
onError: suspend (Throwable) -> Unit = {},
) = asFlow(file, block)
) = asFlow(file)
.catch {
onError(it)
log.warn(it) { "Failed to import backup ${file.name}" }
@@ -32,8 +32,7 @@ class ImportBackupFile
fun asFlow(
file: Path,
block: HttpRequestBuilder.() -> Unit = {},
) = backupRepositoryOld.importBackupFile(BackupRepositoryOld.buildBackupFormData(file), block)
) = backupRepository.restoreBackup(FileSystem.SYSTEM.source(file))
companion object {
private val log = logging()

View File

@@ -6,24 +6,24 @@
package ca.gosyer.jui.domain.backup.interactor
import ca.gosyer.jui.domain.backup.service.BackupRepositoryOld
import io.ktor.client.request.HttpRequestBuilder
import ca.gosyer.jui.domain.backup.service.BackupRepository
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.singleOrNull
import me.tatarka.inject.annotations.Inject
import okio.FileSystem
import okio.Path
import okio.SYSTEM
import org.lighthousegames.logging.logging
class ValidateBackupFile
@Inject
constructor(
private val backupRepositoryOld: BackupRepositoryOld,
private val backupRepository: BackupRepository,
) {
suspend fun await(
file: Path,
block: HttpRequestBuilder.() -> Unit = {},
onError: suspend (Throwable) -> Unit = {},
) = asFlow(file, block)
) = asFlow(file)
.catch {
onError(it)
log.warn(it) { "Failed to validate backup ${file.name}" }
@@ -32,8 +32,7 @@ class ValidateBackupFile
fun asFlow(
file: Path,
block: HttpRequestBuilder.() -> Unit = {},
) = backupRepositoryOld.validateBackupFile(BackupRepositoryOld.buildBackupFormData(file), block)
) = backupRepository.validateBackup(FileSystem.SYSTEM.source(file))
companion object {
private val log = logging()

View File

@@ -0,0 +1,25 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.jui.domain.backup.model
import kotlinx.serialization.Serializable
enum class RestoreState {
IDLE,
SUCCESS,
FAILURE,
RESTORING_CATEGORIES,
RESTORING_MANGA,
UNKNOWN,
}
@Serializable
data class RestoreStatus(
val state: RestoreState,
val completed: Int,
val total: Int,
)

View File

@@ -0,0 +1,18 @@
package ca.gosyer.jui.domain.backup.service
import ca.gosyer.jui.domain.backup.model.BackupValidationResult
import ca.gosyer.jui.domain.backup.model.RestoreStatus
import io.ktor.client.request.HttpRequestBuilder
import kotlinx.coroutines.flow.Flow
import okio.Source
interface BackupRepository {
fun validateBackup(source: Source): Flow<BackupValidationResult>
fun restoreBackup(source: Source): Flow<Pair<String, RestoreStatus>>
fun restoreStatus(id: String): Flow<RestoreStatus>
fun createBackup(
includeCategories: Boolean,
includeChapters: Boolean,
block: HttpRequestBuilder.() -> Unit,
): Flow<Pair<String, Source>>
}

View File

@@ -18,19 +18,21 @@ import okio.Source
import okio.source
actual class FileChooser(
private val resultLauncher: ManagedActivityResultLauncher<String, Uri?>,
private val resultLauncher: ManagedActivityResultLauncher<Array<String>, Uri?>,
) {
actual fun launch(extension: String) {
val mime = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(extension) ?: return
resultLauncher.launch(mime)
actual fun launch(vararg extensions: String) {
val mimes = extensions.mapNotNull { extension ->
MimeTypeMap.getSingleton()
.getMimeTypeFromExtension(extension)
}.toTypedArray()
resultLauncher.launch(mimes)
}
}
@Composable
actual fun rememberFileChooser(onFileFound: (Source) -> Unit): FileChooser {
val context = LocalContext.current
val result = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) {
val result = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
if (it != null) {
context.contentResolver.openInputStream(it)?.source()?.let(onFileFound)
}

View File

@@ -10,7 +10,7 @@ import androidx.compose.runtime.Composable
import okio.Source
expect class FileChooser {
fun launch(extension: String)
fun launch(vararg extensions: String)
}
@Composable

View File

@@ -45,6 +45,8 @@ import ca.gosyer.jui.core.lang.throwIfCancellation
import ca.gosyer.jui.domain.backup.interactor.ExportBackupFile
import ca.gosyer.jui.domain.backup.interactor.ImportBackupFile
import ca.gosyer.jui.domain.backup.interactor.ValidateBackupFile
import ca.gosyer.jui.domain.backup.model.RestoreState
import ca.gosyer.jui.domain.backup.model.RestoreStatus
import ca.gosyer.jui.i18n.MR
import ca.gosyer.jui.ui.base.dialog.getMaterialDialogProperties
import ca.gosyer.jui.ui.base.file.rememberFileChooser
@@ -53,7 +55,6 @@ import ca.gosyer.jui.ui.base.model.StableHolder
import ca.gosyer.jui.ui.base.navigation.Toolbar
import ca.gosyer.jui.ui.base.prefs.PreferenceRow
import ca.gosyer.jui.ui.main.components.bottomNav
import ca.gosyer.jui.ui.util.lang.toSource
import ca.gosyer.jui.ui.viewModel
import ca.gosyer.jui.uicore.components.VerticalScrollbar
import ca.gosyer.jui.uicore.components.rememberScrollbarAdapter
@@ -70,8 +71,6 @@ import com.vanpra.composematerialdialogs.listItems
import com.vanpra.composematerialdialogs.rememberMaterialDialogState
import com.vanpra.composematerialdialogs.title
import io.ktor.client.plugins.onDownload
import io.ktor.client.plugins.onUpload
import io.ktor.client.statement.bodyAsChannel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@@ -172,19 +171,12 @@ class SettingsBackupViewModel
fun restoreBackup(file: Path) {
importBackupFile
.asFlow(file) {
onUpload { bytesSentTotal, contentLength ->
_restoreStatus.value = Status.InProgress(
(bytesSentTotal.toFloat() / contentLength)
.coerceAtMost(1.0F),
)
}
}
.asFlow(file)
.onStart {
_restoreStatus.value = Status.InProgress(null)
}
.onEach {
_restoreStatus.value = Status.Success
_restoreStatus.value = it.second.toStatus()
}
.catch {
toast(it.message.orEmpty())
@@ -194,6 +186,15 @@ class SettingsBackupViewModel
.launchIn(scope)
}
private fun RestoreStatus.toStatus() = when (state) {
RestoreState.IDLE -> Status.Success
RestoreState.SUCCESS -> Status.Success
RestoreState.FAILURE -> Status.Error
RestoreState.RESTORING_CATEGORIES -> Status.InProgress(0.01f)
RestoreState.RESTORING_MANGA -> Status.InProgress((completed.toFloat() / total).coerceIn(0f, 1f))
RestoreState.UNKNOWN -> Status.Error
}
fun stopRestore() {
_restoreStatus.value = Status.Error
}
@@ -203,7 +204,9 @@ class SettingsBackupViewModel
fun exportBackup() {
exportBackupFile
.asFlow {
.asFlow(
true, true // todo
) {
onDownload { bytesSentTotal, contentLength ->
_creatingStatus.value = Status.InProgress(
(bytesSentTotal.toFloat() / contentLength)
@@ -214,15 +217,12 @@ class SettingsBackupViewModel
.onStart {
_creatingStatus.value = Status.InProgress(null)
}
.onEach { backup ->
val filename =
backup.headers["content-disposition"]?.substringAfter("filename=")
?.trim('"') ?: "backup"
.onEach { (filename, source) ->
tempFile.value = FileSystem.SYSTEM_TEMPORARY_DIRECTORY.resolve(filename).also {
mutex.tryLock()
scope.launch {
try {
backup.bodyAsChannel().toSource().saveTo(it)
source.saveTo(it)
} catch (e: Exception) {
e.throwIfCancellation()
log.warn(e) { "Error creating backup" }
@@ -338,7 +338,7 @@ private fun SettingsBackupScreenContent(
stringResource(MR.strings.backup_restore_sub),
restoreStatus,
) {
fileChooser.launch("gz")
fileChooser.launch("gz", "tachibk")
}
PreferenceFile(
stringResource(MR.strings.backup_create),

View File

@@ -1,14 +0,0 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.jui.ui.util.lang
import io.ktor.utils.io.ByteReadChannel
import okio.Source
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
expect suspend fun ByteReadChannel.toSource(context: CoroutineContext = EmptyCoroutineContext): Source

View File

@@ -26,9 +26,9 @@ actual class FileChooser(
details?.actionPerformed(null)
}
actual fun launch(extension: String) {
actual fun launch(vararg extensions: String) {
scope.launchDefault {
fileChooser.fileFilter = FileNameExtensionFilter("$extension file", extension)
fileChooser.fileFilter = FileNameExtensionFilter("${extensions.joinToString()} files", *extensions)
when (fileChooser.showOpenDialog(null)) {
JFileChooser.APPROVE_OPTION -> onFileFound(fileChooser.selectedFile.source())
}

View File

@@ -1,15 +0,0 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package ca.gosyer.jui.ui.util.lang
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.jvm.javaio.toInputStream
import okio.Source
import okio.source
import kotlin.coroutines.CoroutineContext
actual suspend fun ByteReadChannel.toSource(context: CoroutineContext): Source = toInputStream().source()