From 4fe769cbbfde07199483cb9d89826b695c94d59f Mon Sep 17 00:00:00 2001 From: tom5079 Date: Mon, 2 May 2022 21:29:58 +0900 Subject: [PATCH] Pupil-129 [Source] Implement in-app update --- app/build.gradle.kts | 2 + .../java/xyz/quaver/pupil/ui/MainActivity.kt | 20 +++ .../xyz/quaver/pupil/ui/SourceSelector.kt | 26 ++-- .../java/xyz/quaver/pupil/ui/UpdateDialog.kt | 96 +++++++++++++++ .../xyz/quaver/pupil/util/PupilHttpClient.kt | 116 +++++++++++------- .../xyz/quaver/pupil/PupilHttpClientTest.kt | 12 +- buildSrc/src/main/kotlin/Config.kt | 2 + 7 files changed, 212 insertions(+), 62 deletions(-) create mode 100644 app/src/main/java/xyz/quaver/pupil/ui/UpdateDialog.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4e3b5f8f..8b4d9379 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -101,6 +101,8 @@ dependencies { implementation(JetpackCompose.MATERIAL_ICONS) implementation(JetpackCompose.RUNTIME_LIVEDATA) + implementation(JetpackCompose.MARKDOWN) + implementation(Accompanist.INSETS) implementation(Accompanist.INSETS_UI) implementation(Accompanist.FLOW_LAYOUT) diff --git a/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt b/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt index d3265711..7f4a437d 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt @@ -35,9 +35,13 @@ import com.google.accompanist.systemuicontroller.rememberSystemUiController import kotlinx.coroutines.launch import org.kodein.di.DIAware import org.kodein.di.android.closestDI +import org.kodein.di.compose.rememberInstance +import xyz.quaver.pupil.BuildConfig import xyz.quaver.pupil.sources.core.Source import xyz.quaver.pupil.sources.loadSource import xyz.quaver.pupil.ui.theme.PupilTheme +import xyz.quaver.pupil.util.PupilHttpClient +import xyz.quaver.pupil.util.Release class MainActivity : ComponentActivity(), DIAware { override val di by closestDI() @@ -57,6 +61,14 @@ class MainActivity : ComponentActivity(), DIAware { val coroutineScope = rememberCoroutineScope() + val client: PupilHttpClient by rememberInstance() + + val latestRelease by produceState(null) { + value = client.latestRelease() + } + + var dismissUpdate by remember { mutableStateOf(false) } + SideEffect { systemUiController.setSystemBarsColor( color = Color.Transparent, @@ -64,6 +76,14 @@ class MainActivity : ComponentActivity(), DIAware { ) } + latestRelease?.let { release -> + UpdateAlertDialog( + show = !dismissUpdate && release.version != BuildConfig.VERSION_NAME, + release = release, + onDismiss = { dismissUpdate = true } + ) + } + NavHost(navController, "main") { composable("main") { var source by remember { mutableStateOf(null) } diff --git a/app/src/main/java/xyz/quaver/pupil/ui/SourceSelector.kt b/app/src/main/java/xyz/quaver/pupil/ui/SourceSelector.kt index 3882686d..2016857a 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/SourceSelector.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/SourceSelector.kt @@ -55,11 +55,11 @@ import com.google.accompanist.insets.systemBarsPadding import com.google.accompanist.insets.ui.BottomNavigation import com.google.accompanist.insets.ui.Scaffold import com.google.accompanist.insets.ui.TopAppBar -import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.kodein.di.* import org.kodein.di.compose.localDI -import org.kodein.di.compose.rememberInstance import xyz.quaver.pupil.sources.SourceEntry import xyz.quaver.pupil.sources.rememberLocalSourceList import xyz.quaver.pupil.sources.rememberRemoteSourceList @@ -94,19 +94,20 @@ class DownloadApkActionState(override val di: DI) : DIAware { var progress by mutableStateOf(null) private set - suspend fun download(sourceInfo: RemoteSourceInfo): File { + suspend fun download(url: String): File? = withContext(Dispatchers.IO) { progress = 0f - val file = File(app.cacheDir, "apks/${sourceInfo.name}-${sourceInfo.version}.apk").also { + val file = File.createTempFile("pupil", ".apk", File(app.cacheDir, "apks")).also { it.parentFile?.mkdirs() } - client.downloadApk(sourceInfo.apkUrl, file).collect { progress = it } + client.downloadFile(url, file).collect { progress = it } - require(progress == Float.POSITIVE_INFINITY) + if (progress == Float.POSITIVE_INFINITY) file else null + } + fun reset() { progress = null - return file } } @@ -115,7 +116,7 @@ fun rememberDownloadApkActionState(di: DI = localDI()) = remember { DownloadApkA @Composable fun DownloadApkAction( - state: DownloadApkActionState = rememberDownloadApkActionState(), + state: DownloadApkActionState, content: @Composable () -> Unit ) { state.progress?.let { progress -> @@ -207,8 +208,9 @@ fun Local(onSource: (SourceEntry) -> Unit) { if (remoteSource != null && remoteSource.version != source.version) { TextButton(onClick = { coroutineScope.launch { - val file = actionState.download(remoteSource) + val file = actionState.download(remoteSource.apkUrl)!! // TODO("Handle error") context.launchApkInstaller(file) + actionState.reset() } }) { Text("UPDATE") @@ -267,8 +269,9 @@ fun Explore() { if (localSources[sourceInfo.name]?.version != sourceInfo.version) { TextButton(onClick = { coroutineScope.launch { - val file = actionState.download(sourceInfo) + val file = actionState.download(sourceInfo.apkUrl)!! // TODO("Handle exception") context.launchApkInstaller(file) + actionState.reset() } }) { Text("UPDATE") @@ -283,8 +286,9 @@ fun Explore() { ) ) } else coroutineScope.launch { - val file = actionState.download(sourceInfo) + val file = actionState.download(sourceInfo.apkUrl)!! // TODO("Handle exception") context.launchApkInstaller(file) + actionState.reset() } }) { Icon( diff --git a/app/src/main/java/xyz/quaver/pupil/ui/UpdateDialog.kt b/app/src/main/java/xyz/quaver/pupil/ui/UpdateDialog.kt new file mode 100644 index 00000000..79c4965f --- /dev/null +++ b/app/src/main/java/xyz/quaver/pupil/ui/UpdateDialog.kt @@ -0,0 +1,96 @@ +/* + * Pupil, Hitomi.la viewer for Android + * Copyright (C) 2022 tom5079 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package xyz.quaver.pupil.ui + +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import dev.jeziellago.compose.markdowntext.MarkdownText +import kotlinx.coroutines.launch +import org.kodein.di.compose.onDIContext +import xyz.quaver.pupil.util.Release +import xyz.quaver.pupil.util.launchApkInstaller +import java.util.* + +@Composable +fun UpdateAlertDialog( + show: Boolean, + release: Release, + onDismiss: () -> Unit +) { + val state = rememberDownloadApkActionState() + + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + + if (show) { + Dialog(onDismissRequest = { if (state.progress == null) onDismiss() }) { + Card { + val progress = state.progress + + if (progress != null) { + if (progress.isFinite() && progress > 0) + LinearProgressIndicator(progress) + else + LinearProgressIndicator() + } + + Column( + Modifier.padding(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 0.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "Update Available", + style = MaterialTheme.typography.h6 + ) + + MarkdownText(release.releaseNotes.getOrElse(Locale.getDefault()) { release.releaseNotes[Locale.ENGLISH]!! }) + + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss, enabled = progress == null) { + Text("DISMISS") + } + + TextButton( + onClick = { + coroutineScope.launch { + val file = state.download(release.apkUrl)!! // TODO("Handle exception") + context.launchApkInstaller(file) + state.reset() + onDismiss() + } + }, + enabled = progress == null + ) { + Text("UPDATE") + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/util/PupilHttpClient.kt b/app/src/main/java/xyz/quaver/pupil/util/PupilHttpClient.kt index 22d51b61..a6a2a4be 100644 --- a/app/src/main/java/xyz/quaver/pupil/util/PupilHttpClient.kt +++ b/app/src/main/java/xyz/quaver/pupil/util/PupilHttpClient.kt @@ -18,6 +18,7 @@ package xyz.quaver.pupil.util +import androidx.compose.ui.res.stringArrayResource import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.* @@ -46,7 +47,7 @@ data class RemoteSourceInfo( class Release( val version: String, val apkUrl: String, - val updateNotes: Map + val releaseNotes: Map ) private val localeMap = mapOf( @@ -62,69 +63,94 @@ class PupilHttpClient(engine: HttpClientEngine) { } } + /** + * Fetch a list of available sources from PupilSources repository. + * Returns empty map when exception occurs + */ suspend fun getRemoteSourceList(): Map = withContext(Dispatchers.IO) { - httpClient.get("https://tom5079.github.io/PupilSources/versions.json").body() + runCatching { + httpClient.get("https://tom5079.github.io/PupilSources/versions.json").body>() + }.getOrDefault(emptyMap()) } - fun downloadApk(url: String, dest: File) = flow { - httpClient.prepareGet(url).execute { response -> - val channel = response.bodyAsChannel() - val contentLength = response.contentLength() ?: -1 - var readBytes = 0f + /** + * Downloads specific file from :url to :dest. + * Returns flow that emits progress. + * when value emitted by flow { + * in 0f .. 1f -> downloading + * POSITIVE_INFINITY -> download finised + * NEGATIVE_INFINITY -> exception occured + * } + */ + fun downloadFile(url: String, dest: File) = flow { + runCatching { + httpClient.prepareGet(url).execute { response -> + val channel = response.bodyAsChannel() + val contentLength = response.contentLength() ?: -1 + var readBytes = 0f - dest.outputStream().use { outputStream -> - while (!channel.isClosedForRead) { - val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE.toLong()) - while (!packet.isEmpty) { - val bytes = packet.readBytes() - outputStream.write(bytes) + dest.outputStream().use { outputStream -> + while (!channel.isClosedForRead) { + val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE.toLong()) + while (!packet.isEmpty) { + val bytes = packet.readBytes() + outputStream.write(bytes) - readBytes += bytes.size - emit(readBytes / contentLength) + readBytes += bytes.size + emit(readBytes / contentLength) + } } } } - } - emit(Float.POSITIVE_INFINITY) + emit(Float.POSITIVE_INFINITY) + }.onFailure { + emit(Float.NEGATIVE_INFINITY) + } }.flowOn(Dispatchers.IO) - suspend fun latestRelease(beta: Boolean = true): Release = withContext(Dispatchers.IO) { - val releases = Json.parseToJsonElement( - httpClient.get("https://api.github.com/repos/tom5079/Pupil/releases").bodyAsText() - ).jsonArray + /** + * Latest application release info from Github API. + * Returns null when exception occurs. + */ + suspend fun latestRelease(beta: Boolean = true): Release? = withContext(Dispatchers.IO) { + runCatching { + val releases = Json.parseToJsonElement( + httpClient.get("https://api.github.com/repos/tom5079/Pupil/releases").bodyAsText() + ).jsonArray - val latestRelease = releases.first { release -> - beta || !release.jsonObject["prerelease"]!!.jsonPrimitive.boolean - }.jsonObject + val latestRelease = releases.first { release -> + beta || !release.jsonObject["prerelease"]!!.jsonPrimitive.boolean + }.jsonObject - val version = latestRelease["tag_name"]!!.jsonPrimitive.content + val version = latestRelease["tag_name"]!!.jsonPrimitive.content - val apkUrl = latestRelease["assets"]!!.jsonArray.first { asset -> - val name = asset.jsonObject["name"]!!.jsonPrimitive.content - name.startsWith("Pupil-v") && name.endsWith(".apk") - }.jsonObject["browser_download_url"]!!.jsonPrimitive.content + val apkUrl = latestRelease["assets"]!!.jsonArray.first { asset -> + val name = asset.jsonObject["name"]!!.jsonPrimitive.content + name.startsWith("Pupil-v") && name.endsWith(".apk") + }.jsonObject["browser_download_url"]!!.jsonPrimitive.content - val updateNotes: Map = buildMap { - val body = latestRelease["body"]!!.jsonPrimitive.content + val releaseNotes: Map = buildMap { + val body = latestRelease["body"]!!.jsonPrimitive.content - var locale: Locale? = null - val stringBuilder = StringBuilder() - body.lineSequence().forEach { line -> - localeMap[line.drop(3)]?.let { newLocale -> - if (locale != null) { - put(locale!!, stringBuilder.deleteCharAt(stringBuilder.length-1).toString()) - stringBuilder.clear() + var locale: Locale? = null + val stringBuilder = StringBuilder() + body.lineSequence().forEach { line -> + localeMap[line.drop(3)]?.let { newLocale -> + if (locale != null) { + put(locale!!, stringBuilder.deleteCharAt(stringBuilder.length-1).toString()) + stringBuilder.clear() + } + locale = newLocale + return@forEach } - locale = newLocale - return@forEach + + if (locale != null) stringBuilder.appendLine(line) } - - if (locale != null) stringBuilder.appendLine(line) + put(locale!!, stringBuilder.deleteCharAt(stringBuilder.length-1).toString()) } - put(locale!!, stringBuilder.deleteCharAt(stringBuilder.length-1).toString()) - } - Release(version, apkUrl, updateNotes) + Release(version, apkUrl, releaseNotes) + }.getOrNull() } } \ No newline at end of file diff --git a/app/src/test/java/xyz/quaver/pupil/PupilHttpClientTest.kt b/app/src/test/java/xyz/quaver/pupil/PupilHttpClientTest.kt index e994d432..08d94861 100644 --- a/app/src/test/java/xyz/quaver/pupil/PupilHttpClientTest.kt +++ b/app/src/test/java/xyz/quaver/pupil/PupilHttpClientTest.kt @@ -66,7 +66,7 @@ class PupilHttpClientTest { val client = PupilHttpClient(mockEngine) - client.downloadApk("http://a/", tempFile).collect() + client.downloadFile("http://a/", tempFile).collect() assertArrayEquals(expected, tempFile.readBytes()) } @@ -75,7 +75,7 @@ class PupilHttpClientTest { fun latestRelease() = runTest { val expectedVersion = "5.3.7" val expectedApkUrl = "https://github.com/tom5079/Pupil/releases/download/5.3.7/Pupil-v5.3.7.apk" - val expectedUpdateNotes = mapOf( + val expectedReleaseNotes = mapOf( Locale.KOREAN to """ * 가끔씩 무한로딩 걸리는 현상 수정 * 백업시 즐겨찾기 태그도 백업되게 수정 @@ -100,14 +100,14 @@ class PupilHttpClientTest { val client = PupilHttpClient(mockEngine) - val release = client.latestRelease() + val release = client.latestRelease()!! assertEquals(expectedVersion, release.version) assertEquals(expectedApkUrl, release.apkUrl) - println(expectedUpdateNotes) - println(release.updateNotes) - assertEquals(expectedUpdateNotes, release.updateNotes) + println(expectedReleaseNotes) + println(release.releaseNotes) + assertEquals(expectedReleaseNotes, release.releaseNotes) } } \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index ae3d92df..987b3a61 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -45,6 +45,8 @@ object JetpackCompose { const val RUNTIME_LIVEDATA = "androidx.compose.runtime:runtime-livedata:${Versions.JETPACK_COMPOSE}" const val UI_UTIL = "androidx.compose.ui:ui-util:${Versions.JETPACK_COMPOSE}" const val ANIMATION = "androidx.compose.animation:animation:${Versions.JETPACK_COMPOSE}" + + const val MARKDOWN = "com.github.jeziellago:compose-markdown:0.2.9" } object Accompanist {