Update Ktor, use new progress feature, fix backup creation

This commit is contained in:
Syer10
2021-05-28 16:15:03 -04:00
parent 26d8f0145d
commit 6d47cea979
15 changed files with 142 additions and 56 deletions

View File

@@ -46,7 +46,7 @@ dependencies {
kapt("com.github.stephanenicolas.toothpick:toothpick-compiler:3.1.0")
// Http client
val ktorVersion = "1.5.4"
val ktorVersion = "1.6.0"
implementation("io.ktor:ktor-client-core:$ktorVersion")
implementation("io.ktor:ktor-client-okhttp:$ktorVersion")
implementation("io.ktor:ktor-client-serialization:$ktorVersion")

View File

@@ -14,13 +14,13 @@ import ca.gosyer.data.server.requests.backupFileExportRequest
import ca.gosyer.data.server.requests.backupFileImportRequest
import ca.gosyer.data.server.requests.backupImportRequest
import ca.gosyer.util.lang.withIOContext
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.forms.formData
import io.ktor.client.request.forms.submitFormWithBinaryData
import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType
import io.ktor.http.Headers
import io.ktor.http.HttpHeaders
import io.ktor.http.content.MultiPartData
import io.ktor.http.contentType
import java.io.File
import javax.inject.Inject
@@ -30,7 +30,7 @@ class BackupInteractionHandler @Inject constructor(
serverPreferences: ServerPreferences
) : BaseInteractionHandler(client, serverPreferences) {
suspend fun importBackupFile(file: File) = withIOContext {
suspend fun importBackupFile(file: File, block: HttpRequestBuilder.() -> Unit = {}) = withIOContext {
client.submitFormWithBinaryData<HttpResponse>(
serverUrl + backupFileImportRequest(),
formData = formData {
@@ -41,28 +41,32 @@ class BackupInteractionHandler @Inject constructor(
append(HttpHeaders.ContentDisposition, "filename=backup.json")
}
)
}
},
block = block
)
}
suspend fun importBackup(backup: Backup) = withIOContext {
suspend fun importBackup(backup: Backup, block: HttpRequestBuilder.() -> Unit = {}) = withIOContext {
client.postRepeat<HttpResponse>(
serverUrl + backupImportRequest()
) {
contentType(ContentType.Application.Json)
body = backup
block()
}
}
suspend fun exportBackupFile() = withIOContext {
client.getRepeat<MultiPartData>(
serverUrl + backupFileExportRequest()
suspend fun exportBackupFile(block: HttpRequestBuilder.() -> Unit = {}) = withIOContext {
client.getRepeat<HttpResponse>(
serverUrl + backupFileExportRequest(),
block
)
}
suspend fun exportBackup() = withIOContext {
suspend fun exportBackup(block: HttpRequestBuilder.() -> Unit = {}) = withIOContext {
client.getRepeat<Backup>(
serverUrl + backupExportRequest()
serverUrl + backupExportRequest(),
block
)
}
}

View File

@@ -87,9 +87,9 @@ open class BaseInteractionHandler(
}
}
suspend fun imageFromUrl(client: Http, imageUrl: String): ImageBitmap {
suspend fun imageFromUrl(client: Http, imageUrl: String, block: HttpRequestBuilder.() -> Unit): ImageBitmap {
return repeat {
ca.gosyer.util.compose.imageFromUrl(client, imageUrl)
ca.gosyer.util.compose.imageFromUrl(client, imageUrl, block)
}
}
}

View File

@@ -15,6 +15,7 @@ import ca.gosyer.data.server.requests.getMangaChaptersQuery
import ca.gosyer.data.server.requests.getPageQuery
import ca.gosyer.data.server.requests.updateChapterRequest
import ca.gosyer.util.lang.withIOContext
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.parameter
import io.ktor.client.statement.HttpResponse
import io.ktor.http.HttpMethod
@@ -113,16 +114,17 @@ class ChapterInteractionHandler @Inject constructor(
markPreviousRead
)
suspend fun getPage(mangaId: Long, chapterIndex: Int, pageNum: Int) = withIOContext {
suspend fun getPage(mangaId: Long, chapterIndex: Int, pageNum: Int, block: HttpRequestBuilder.() -> Unit) = withIOContext {
imageFromUrl(
client,
serverUrl + getPageQuery(mangaId, chapterIndex, pageNum)
serverUrl + getPageQuery(mangaId, chapterIndex, pageNum),
block
)
}
suspend fun getPage(chapter: Chapter, pageNum: Int) = getPage(chapter.mangaId, chapter.index, pageNum)
suspend fun getPage(chapter: Chapter, pageNum: Int, block: HttpRequestBuilder.() -> Unit) = getPage(chapter.mangaId, chapter.index, pageNum, block)
suspend fun getPage(manga: Manga, chapterIndex: Int, pageNum: Int) = getPage(manga.id, chapterIndex, pageNum)
suspend fun getPage(manga: Manga, chapterIndex: Int, pageNum: Int, block: HttpRequestBuilder.() -> Unit) = getPage(manga.id, chapterIndex, pageNum, block)
suspend fun getPage(manga: Manga, chapter: Chapter, pageNum: Int) = getPage(manga.id, chapter.index, pageNum)
suspend fun getPage(manga: Manga, chapter: Chapter, pageNum: Int, block: HttpRequestBuilder.() -> Unit) = getPage(manga.id, chapter.index, pageNum, block)
}

View File

@@ -15,6 +15,7 @@ import ca.gosyer.data.server.requests.apkUninstallQuery
import ca.gosyer.data.server.requests.apkUpdateQuery
import ca.gosyer.data.server.requests.extensionListQuery
import ca.gosyer.util.lang.withIOContext
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.statement.HttpResponse
import javax.inject.Inject
@@ -47,10 +48,11 @@ class ExtensionInteractionHandler @Inject constructor(
)
}
suspend fun getApkIcon(extension: Extension) = withIOContext {
suspend fun getApkIcon(extension: Extension, block: HttpRequestBuilder.() -> Unit) = withIOContext {
imageFromUrl(
client,
serverUrl + apkIconQuery(extension.apkName)
serverUrl + apkIconQuery(extension.apkName),
block
)
}
}

View File

@@ -12,6 +12,7 @@ import ca.gosyer.data.server.ServerPreferences
import ca.gosyer.data.server.requests.mangaQuery
import ca.gosyer.data.server.requests.mangaThumbnailQuery
import ca.gosyer.util.lang.withIOContext
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.parameter
import javax.inject.Inject
@@ -34,10 +35,11 @@ class MangaInteractionHandler @Inject constructor(
suspend fun getManga(manga: Manga, refresh: Boolean = false) = getManga(manga.id, refresh)
suspend fun getMangaThumbnail(mangaId: Long) = withIOContext {
suspend fun getMangaThumbnail(mangaId: Long, block: HttpRequestBuilder.() -> Unit) = withIOContext {
imageFromUrl(
client,
serverUrl + mangaThumbnailQuery(mangaId)
serverUrl + mangaThumbnailQuery(mangaId),
block
)
}
}

View File

@@ -24,10 +24,13 @@ import ca.gosyer.common.di.AppScope
import ca.gosyer.data.server.Http
import ca.gosyer.util.compose.imageFromUrl
import ca.gosyer.util.lang.throwIfCancellation
import io.ktor.client.features.onDownload
import io.ktor.client.request.HttpRequestBuilder
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlin.math.max
@OptIn(DelicateCoroutinesApi::class)
@Composable
@@ -46,6 +49,7 @@ fun KtorImage(
BoxWithConstraints {
val drawable: MutableState<ImageBitmap?> = remember { mutableStateOf(null) }
val loading: MutableState<Boolean> = remember { mutableStateOf(true) }
val progress: MutableState<Float?> = remember { mutableStateOf(null) }
val error: MutableState<String?> = remember { mutableStateOf(null) }
DisposableEffect(imageUrl) {
val handler = CoroutineExceptionHandler { _, throwable ->
@@ -54,7 +58,11 @@ fun KtorImage(
}
val job = GlobalScope.launch(handler) {
if (drawable.value == null) {
drawable.value = getImage(client, imageUrl, retries)
drawable.value = getImage(client, imageUrl, retries) {
onDownload { bytesSentTotal, contentLength ->
progress.value = max(bytesSentTotal.toFloat() / contentLength, 1.0F)
}
}
}
loading.value = false
}
@@ -77,17 +85,17 @@ fun KtorImage(
colorFilter = colorFilter
)
} else {
LoadingScreen(loading.value, loadingModifier, error.value)
LoadingScreen(loading.value, loadingModifier, progress.value, error.value)
}
}
}
private suspend fun getImage(client: Http, imageUrl: String, retries: Int = 3): ImageBitmap {
private suspend fun getImage(client: Http, imageUrl: String, retries: Int = 3, block: HttpRequestBuilder.() -> Unit): ImageBitmap {
var attempt = 1
var lastException: Exception
do {
try {
return imageFromUrl(client, imageUrl)
return imageFromUrl(client, imageUrl, block)
} catch (e: Exception) {
e.throwIfCancellation()
lastException = e

View File

@@ -21,6 +21,8 @@ import androidx.compose.ui.unit.min
fun LoadingScreen(
isLoading: Boolean = true,
modifier: Modifier = Modifier.fillMaxSize(),
/*@FloatRange(from = 0.0, to = 1.0)*/
progress: Float? = null,
errorMessage: String? = null,
retryMessage: String = "Retry",
retry: (() -> Unit)? = null
@@ -31,7 +33,11 @@ fun LoadingScreen(
val size = remember(maxHeight, maxWidth) {
min(maxHeight, maxWidth) / 2
}
if (progress != null) {
CircularProgressIndicator(progress, Modifier.align(Alignment.Center).size(size))
} else {
CircularProgressIndicator(Modifier.align(Alignment.Center).size(size))
}
} else {
ErrorScreen(errorMessage, modifier, retryMessage, retry)
}

View File

@@ -201,6 +201,7 @@ fun ReaderMenu(chapterIndex: Int, mangaId: Long, setHotkeys: (List<KeyboardShort
fun ReaderImage(
imageIndex: Int,
drawable: ImageBitmap?,
progress: Float?,
status: ReaderPage.Status,
error: String?,
imageModifier: Modifier = Modifier.fillMaxSize(),
@@ -216,7 +217,7 @@ fun ReaderImage(
contentScale = contentScale
)
} else {
LoadingScreen(status == ReaderPage.Status.QUEUE, loadingModifier, error) { retry(imageIndex) }
LoadingScreen(status == ReaderPage.Status.QUEUE, loadingModifier, progress, error) { retry(imageIndex) }
}
}

View File

@@ -13,6 +13,7 @@ import ca.gosyer.ui.reader.model.ReaderPage
import ca.gosyer.util.lang.throwIfCancellation
import ca.gosyer.util.system.CKLogger
import io.github.kerubistan.kroki.coroutines.priorityChannel
import io.ktor.client.features.onDownload
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
@@ -22,6 +23,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicInteger
import kotlin.coroutines.CoroutineContext
import kotlin.math.max
class TachideskPageLoader(
context: CoroutineContext,
@@ -58,7 +60,11 @@ class TachideskPageLoader(
debug { "Loading page ${page.index}" }
if (page.status.value == ReaderPage.Status.QUEUE) {
try {
page.bitmap.value = chapterHandler.getPage(chapter.chapter, page.index)
page.bitmap.value = chapterHandler.getPage(chapter.chapter, page.index) {
onDownload { bytesSentTotal, contentLength ->
page.progress.value = max(bytesSentTotal.toFloat() / contentLength, 1.0F)
}
}
page.status.value = ReaderPage.Status.READY
page.error.value = null
} catch (e: Exception) {
@@ -107,6 +113,7 @@ class TachideskPageLoader(
ReaderPage(
it,
MutableStateFlow(null),
MutableStateFlow(null),
MutableStateFlow(ReaderPage.Status.QUEUE),
MutableStateFlow(null)
)

View File

@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
data class ReaderPage(
val index: Int,
val bitmap: MutableStateFlow<ImageBitmap?>,
val progress: MutableStateFlow<Float?>,
val status: MutableStateFlow<Status>,
val error: MutableStateFlow<String?>
) {

View File

@@ -63,6 +63,7 @@ fun ContinuousReader(
ReaderImage(
image.index,
image.bitmap.collectAsState().value,
image.progress.collectAsState().value,
image.status.collectAsState().value,
image.error.collectAsState().value,
loadingModifier = pageModifier,

View File

@@ -101,6 +101,7 @@ fun HandlePager(
ReaderImage(
image.index,
image.bitmap.collectAsState().value,
image.progress.collectAsState().value,
image.status.collectAsState().value,
image.error.collectAsState().value,
loadingModifier = pageModifier,

View File

@@ -13,6 +13,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Warning
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
@@ -32,39 +33,53 @@ import ca.gosyer.util.system.CKLogger
import ca.gosyer.util.system.filePicker
import ca.gosyer.util.system.fileSaver
import com.github.zsoltk.compose.router.BackStack
import io.ktor.client.features.onDownload
import io.ktor.client.features.onUpload
import io.ktor.utils.io.jvm.javaio.copyTo
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
import kotlin.math.max
class SettingsBackupViewModel @Inject constructor(
private val backupHandler: BackupInteractionHandler
) : ViewModel() {
private val _restoring = MutableStateFlow(false)
val restoring = _restoring.asStateFlow()
private val _restoreError = MutableStateFlow(false)
val restoreError = _restoreError.asStateFlow()
private val _restoringProgress = MutableStateFlow<Float?>(null)
val restoringProgress = _restoringProgress.asStateFlow()
private val _restoreStatus = MutableStateFlow<Status>(Status.Nothing)
internal val restoreStatus = _restoreStatus.asStateFlow()
private val _creating = MutableStateFlow(false)
val creating = _creating.asStateFlow()
private val _creatingError = MutableStateFlow(false)
val creatingError = _creatingError.asStateFlow()
private val _creatingProgress = MutableStateFlow<Float?>(null)
val creatingProgress = _creatingProgress.asStateFlow()
private val _creatingStatus = MutableStateFlow<Status>(Status.Nothing)
internal val creatingStatus = _creatingStatus.asStateFlow()
fun restoreFile(file: File?) {
scope.launch {
if (file == null || !file.exists()) {
info { "Invalid file ${file?.absolutePath}" }
} else {
_restoreError.value = false
_restoreStatus.value = Status.Nothing
_restoringProgress.value = null
_restoring.value = true
try {
backupHandler.importBackupFile(file)
backupHandler.importBackupFile(file) {
onUpload { bytesSentTotal, contentLength ->
_restoringProgress.value = max(bytesSentTotal.toFloat() / contentLength, 1.0F)
}
}
} catch (e: Exception) {
info(e) { "Error importing backup" }
_restoreError.value = true
_restoreStatus.value = Status.Error
} finally {
_restoring.value = false
_restoreStatus.value = Status.Success
}
}
}
@@ -76,20 +91,36 @@ class SettingsBackupViewModel @Inject constructor(
info { "Invalid file ${file?.absolutePath}" }
} else {
if (file.exists()) file.delete()
_creatingError.value = false
_creatingStatus.value = Status.Nothing
_creatingProgress.value = null
_creating.value = true
try {
val backup = backupHandler.exportBackupFile()
val backup = backupHandler.exportBackupFile {
onDownload { bytesSentTotal, contentLength ->
_creatingProgress.value = max(bytesSentTotal.toFloat() / contentLength, 0.99F)
}
}
file.outputStream().use {
backup.content.copyTo(it)
}
} catch (e: Exception) {
info(e) { "Error exporting backup" }
_creatingError.value = true
_creatingStatus.value = Status.Error
} finally {
_creatingProgress.value = 1.0F
_creating.value = false
_creatingStatus.value = Status.Success
}
}
}
}
internal sealed class Status {
object Nothing : Status()
object Success : Status()
object Error : Status()
}
private companion object : CKLogger({})
}
@@ -97,9 +128,11 @@ class SettingsBackupViewModel @Inject constructor(
fun SettingsBackupScreen(navController: BackStack<Route>) {
val vm = viewModel<SettingsBackupViewModel>()
val restoring by vm.restoring.collectAsState()
val restoreError by vm.restoreError.collectAsState()
val restoringProgress by vm.restoringProgress.collectAsState()
val restoreStatus by vm.restoreStatus.collectAsState()
val creating by vm.creating.collectAsState()
val creatingError by vm.creatingError.collectAsState()
val creatingProgress by vm.creatingProgress.collectAsState()
val creatingStatus by vm.creatingStatus.collectAsState()
Column {
Toolbar("Backup Settings", navController, true)
LazyColumn {
@@ -108,7 +141,8 @@ fun SettingsBackupScreen(navController: BackStack<Route>) {
"Restore Backup",
"Restore a backup into Tachidesk",
restoring,
restoreError
restoringProgress,
restoreStatus
) {
filePicker("json") {
vm.restoreFile(it.selectedFile)
@@ -118,9 +152,10 @@ fun SettingsBackupScreen(navController: BackStack<Route>) {
"Create Backup",
"Create a backup from Tachidesk",
creating,
creatingError
creatingProgress,
creatingStatus
) {
fileSaver("test.json", "json") {
fileSaver("backup.json", "json") {
vm.createFile(it.selectedFile)
}
}
@@ -130,7 +165,7 @@ fun SettingsBackupScreen(navController: BackStack<Route>) {
}
@Composable
fun PreferenceFile(title: String, subtitle: String, working: Boolean, error: Boolean, onClick: () -> Unit) {
private fun PreferenceFile(title: String, subtitle: String, working: Boolean, progress: Float?, status: SettingsBackupViewModel.Status, onClick: () -> Unit) {
PreferenceRow(
title = title,
onClick = onClick,
@@ -144,16 +179,31 @@ fun PreferenceFile(title: String, subtitle: String, working: Boolean, error: Boo
val modifier = Modifier.align(Alignment.Center)
.size(size)
if (working) {
if (progress != null) {
CircularProgressIndicator(
progress,
modifier
)
} else {
CircularProgressIndicator(
modifier
)
} else if (error) {
Icon(
}
} else if (status != SettingsBackupViewModel.Status.Nothing) {
when (status) {
SettingsBackupViewModel.Status.Error -> Icon(
Icons.Default.Warning,
contentDescription = null,
modifier = modifier,
tint = Color.Red
)
SettingsBackupViewModel.Status.Success -> Icon(
Icons.Default.Check,
contentDescription = null,
modifier = modifier
)
else -> Unit
}
}
}
}

View File

@@ -9,6 +9,7 @@ package ca.gosyer.util.compose
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import ca.gosyer.data.server.Http
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.readBytes
@@ -19,6 +20,6 @@ fun imageFromFile(file: File): ImageBitmap {
return Image.makeFromEncoded(file.readBytes()).asImageBitmap()
}
suspend fun imageFromUrl(client: Http, url: String): ImageBitmap {
return Image.makeFromEncoded(client.get<HttpResponse>(url).readBytes()).asImageBitmap()
suspend fun imageFromUrl(client: Http, url: String, block: HttpRequestBuilder.() -> Unit): ImageBitmap {
return Image.makeFromEncoded(client.get<HttpResponse>(url, block).readBytes()).asImageBitmap()
}