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.MATERIAL_ICONS)
implementation(JetpackCompose.RUNTIME_LIVEDATA) implementation(JetpackCompose.RUNTIME_LIVEDATA)
implementation(JetpackCompose.MARKDOWN)
implementation(Accompanist.INSETS) implementation(Accompanist.INSETS)
implementation(Accompanist.INSETS_UI) implementation(Accompanist.INSETS_UI)
implementation(Accompanist.FLOW_LAYOUT) implementation(Accompanist.FLOW_LAYOUT)

View File

@@ -35,9 +35,13 @@ import com.google.accompanist.systemuicontroller.rememberSystemUiController
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.kodein.di.DIAware import org.kodein.di.DIAware
import org.kodein.di.android.closestDI 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.core.Source
import xyz.quaver.pupil.sources.loadSource import xyz.quaver.pupil.sources.loadSource
import xyz.quaver.pupil.ui.theme.PupilTheme import xyz.quaver.pupil.ui.theme.PupilTheme
import xyz.quaver.pupil.util.PupilHttpClient
import xyz.quaver.pupil.util.Release
class MainActivity : ComponentActivity(), DIAware { class MainActivity : ComponentActivity(), DIAware {
override val di by closestDI() override val di by closestDI()
@@ -57,6 +61,14 @@ class MainActivity : ComponentActivity(), DIAware {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val client: PupilHttpClient by rememberInstance()
val latestRelease by produceState<Release?>(null) {
value = client.latestRelease()
}
var dismissUpdate by remember { mutableStateOf(false) }
SideEffect { SideEffect {
systemUiController.setSystemBarsColor( systemUiController.setSystemBarsColor(
color = Color.Transparent, 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") { NavHost(navController, "main") {
composable("main") { composable("main") {
var source by remember { mutableStateOf<Source?>(null) } 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.BottomNavigation
import com.google.accompanist.insets.ui.Scaffold import com.google.accompanist.insets.ui.Scaffold
import com.google.accompanist.insets.ui.TopAppBar import com.google.accompanist.insets.ui.TopAppBar
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.kodein.di.* import org.kodein.di.*
import org.kodein.di.compose.localDI import org.kodein.di.compose.localDI
import org.kodein.di.compose.rememberInstance
import xyz.quaver.pupil.sources.SourceEntry import xyz.quaver.pupil.sources.SourceEntry
import xyz.quaver.pupil.sources.rememberLocalSourceList import xyz.quaver.pupil.sources.rememberLocalSourceList
import xyz.quaver.pupil.sources.rememberRemoteSourceList import xyz.quaver.pupil.sources.rememberRemoteSourceList
@@ -94,19 +94,20 @@ class DownloadApkActionState(override val di: DI) : DIAware {
var progress by mutableStateOf<Float?>(null) var progress by mutableStateOf<Float?>(null)
private set private set
suspend fun download(sourceInfo: RemoteSourceInfo): File { suspend fun download(url: String): File? = withContext(Dispatchers.IO) {
progress = 0f 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() 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 progress = null
return file
} }
} }
@@ -115,7 +116,7 @@ fun rememberDownloadApkActionState(di: DI = localDI()) = remember { DownloadApkA
@Composable @Composable
fun DownloadApkAction( fun DownloadApkAction(
state: DownloadApkActionState = rememberDownloadApkActionState(), state: DownloadApkActionState,
content: @Composable () -> Unit content: @Composable () -> Unit
) { ) {
state.progress?.let { progress -> state.progress?.let { progress ->
@@ -207,8 +208,9 @@ fun Local(onSource: (SourceEntry) -> Unit) {
if (remoteSource != null && remoteSource.version != source.version) { if (remoteSource != null && remoteSource.version != source.version) {
TextButton(onClick = { TextButton(onClick = {
coroutineScope.launch { coroutineScope.launch {
val file = actionState.download(remoteSource) val file = actionState.download(remoteSource.apkUrl)!! // TODO("Handle error")
context.launchApkInstaller(file) context.launchApkInstaller(file)
actionState.reset()
} }
}) { }) {
Text("UPDATE") Text("UPDATE")
@@ -267,8 +269,9 @@ fun Explore() {
if (localSources[sourceInfo.name]?.version != sourceInfo.version) { if (localSources[sourceInfo.name]?.version != sourceInfo.version) {
TextButton(onClick = { TextButton(onClick = {
coroutineScope.launch { coroutineScope.launch {
val file = actionState.download(sourceInfo) val file = actionState.download(sourceInfo.apkUrl)!! // TODO("Handle exception")
context.launchApkInstaller(file) context.launchApkInstaller(file)
actionState.reset()
} }
}) { }) {
Text("UPDATE") Text("UPDATE")
@@ -283,8 +286,9 @@ fun Explore() {
) )
) )
} else coroutineScope.launch { } else coroutineScope.launch {
val file = actionState.download(sourceInfo) val file = actionState.download(sourceInfo.apkUrl)!! // TODO("Handle exception")
context.launchApkInstaller(file) context.launchApkInstaller(file)
actionState.reset()
} }
}) { }) {
Icon( 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 package xyz.quaver.pupil.util
import androidx.compose.ui.res.stringArrayResource
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.call.* import io.ktor.client.call.*
import io.ktor.client.engine.* import io.ktor.client.engine.*
@@ -46,7 +47,7 @@ data class RemoteSourceInfo(
class Release( class Release(
val version: String, val version: String,
val apkUrl: String, val apkUrl: String,
val updateNotes: Map<Locale, String> val releaseNotes: Map<Locale, String>
) )
private val localeMap = mapOf( 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) { 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 -> * Downloads specific file from :url to :dest.
val channel = response.bodyAsChannel() * Returns flow that emits progress.
val contentLength = response.contentLength() ?: -1 * when value emitted by flow {
var readBytes = 0f * 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 -> dest.outputStream().use { outputStream ->
while (!channel.isClosedForRead) { while (!channel.isClosedForRead) {
val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE.toLong()) val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
while (!packet.isEmpty) { while (!packet.isEmpty) {
val bytes = packet.readBytes() val bytes = packet.readBytes()
outputStream.write(bytes) outputStream.write(bytes)
readBytes += bytes.size readBytes += bytes.size
emit(readBytes / contentLength) emit(readBytes / contentLength)
}
} }
} }
} }
}
emit(Float.POSITIVE_INFINITY) emit(Float.POSITIVE_INFINITY)
}.onFailure {
emit(Float.NEGATIVE_INFINITY)
}
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)
suspend fun latestRelease(beta: Boolean = true): Release = withContext(Dispatchers.IO) { /**
val releases = Json.parseToJsonElement( * Latest application release info from Github API.
httpClient.get("https://api.github.com/repos/tom5079/Pupil/releases").bodyAsText() * Returns null when exception occurs.
).jsonArray */
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 -> val latestRelease = releases.first { release ->
beta || !release.jsonObject["prerelease"]!!.jsonPrimitive.boolean beta || !release.jsonObject["prerelease"]!!.jsonPrimitive.boolean
}.jsonObject }.jsonObject
val version = latestRelease["tag_name"]!!.jsonPrimitive.content val version = latestRelease["tag_name"]!!.jsonPrimitive.content
val apkUrl = latestRelease["assets"]!!.jsonArray.first { asset -> val apkUrl = latestRelease["assets"]!!.jsonArray.first { asset ->
val name = asset.jsonObject["name"]!!.jsonPrimitive.content val name = asset.jsonObject["name"]!!.jsonPrimitive.content
name.startsWith("Pupil-v") && name.endsWith(".apk") name.startsWith("Pupil-v") && name.endsWith(".apk")
}.jsonObject["browser_download_url"]!!.jsonPrimitive.content }.jsonObject["browser_download_url"]!!.jsonPrimitive.content
val updateNotes: Map<Locale, String> = buildMap { val releaseNotes: Map<Locale, String> = buildMap {
val body = latestRelease["body"]!!.jsonPrimitive.content val body = latestRelease["body"]!!.jsonPrimitive.content
var locale: Locale? = null var locale: Locale? = null
val stringBuilder = StringBuilder() val stringBuilder = StringBuilder()
body.lineSequence().forEach { line -> body.lineSequence().forEach { line ->
localeMap[line.drop(3)]?.let { newLocale -> localeMap[line.drop(3)]?.let { newLocale ->
if (locale != null) { if (locale != null) {
put(locale!!, stringBuilder.deleteCharAt(stringBuilder.length-1).toString()) put(locale!!, stringBuilder.deleteCharAt(stringBuilder.length-1).toString())
stringBuilder.clear() stringBuilder.clear()
}
locale = newLocale
return@forEach
} }
locale = newLocale
return@forEach if (locale != null) stringBuilder.appendLine(line)
} }
put(locale!!, stringBuilder.deleteCharAt(stringBuilder.length-1).toString())
if (locale != null) stringBuilder.appendLine(line)
} }
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) val client = PupilHttpClient(mockEngine)
client.downloadApk("http://a/", tempFile).collect() client.downloadFile("http://a/", tempFile).collect()
assertArrayEquals(expected, tempFile.readBytes()) assertArrayEquals(expected, tempFile.readBytes())
} }
@@ -75,7 +75,7 @@ class PupilHttpClientTest {
fun latestRelease() = runTest { fun latestRelease() = runTest {
val expectedVersion = "5.3.7" val expectedVersion = "5.3.7"
val expectedApkUrl = "https://github.com/tom5079/Pupil/releases/download/5.3.7/Pupil-v5.3.7.apk" 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 """ Locale.KOREAN to """
* 가끔씩 무한로딩 걸리는 현상 수정 * 가끔씩 무한로딩 걸리는 현상 수정
* 백업시 즐겨찾기 태그도 백업되게 수정 * 백업시 즐겨찾기 태그도 백업되게 수정
@@ -100,14 +100,14 @@ class PupilHttpClientTest {
val client = PupilHttpClient(mockEngine) val client = PupilHttpClient(mockEngine)
val release = client.latestRelease() val release = client.latestRelease()!!
assertEquals(expectedVersion, release.version) assertEquals(expectedVersion, release.version)
assertEquals(expectedApkUrl, release.apkUrl) assertEquals(expectedApkUrl, release.apkUrl)
println(expectedUpdateNotes) println(expectedReleaseNotes)
println(release.updateNotes) println(release.releaseNotes)
assertEquals(expectedUpdateNotes, release.updateNotes) 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 RUNTIME_LIVEDATA = "androidx.compose.runtime:runtime-livedata:${Versions.JETPACK_COMPOSE}"
const val UI_UTIL = "androidx.compose.ui:ui-util:${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 ANIMATION = "androidx.compose.animation:animation:${Versions.JETPACK_COMPOSE}"
const val MARKDOWN = "com.github.jeziellago:compose-markdown:0.2.9"
} }
object Accompanist { object Accompanist {