Pupil-129 [Source] Implement in-app update

This commit is contained in:
tom5079
2022-05-02 21:29:58 +09:00
parent de068a760e
commit 4fe769cbbf
7 changed files with 212 additions and 62 deletions

View File

@@ -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)

View File

@@ -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<Release?>(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<Source?>(null) }

View File

@@ -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<Float?>(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(

View File

@@ -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 <http://www.gnu.org/licenses/>.
*/
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")
}
}
}
}
}
}
}

View File

@@ -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<Locale, String>
val releaseNotes: Map<Locale, String>
)
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<String, RemoteSourceInfo> = withContext(Dispatchers.IO) {
httpClient.get("https://tom5079.github.io/PupilSources/versions.json").body()
runCatching {
httpClient.get("https://tom5079.github.io/PupilSources/versions.json").body<Map<String, RemoteSourceInfo>>()
}.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<Locale, String> = buildMap {
val body = latestRelease["body"]!!.jsonPrimitive.content
val releaseNotes: Map<Locale, String> = 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()
}
}

View File

@@ -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)
}
}

View File

@@ -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 {