Pupil-129 [Source] Implement in-app update
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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(
|
||||
|
||||
96
app/src/main/java/xyz/quaver/pupil/ui/UpdateDialog.kt
Normal file
96
app/src/main/java/xyz/quaver/pupil/ui/UpdateDialog.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user