Merge pull request #133 from tom5079/Pupil-129
Pupil-129 [Source] Implement in-app update
This commit is contained in:
@@ -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)
|
||||||
@@ -108,7 +110,7 @@ dependencies {
|
|||||||
implementation(Accompanist.DRAWABLE_PAINTER)
|
implementation(Accompanist.DRAWABLE_PAINTER)
|
||||||
implementation(Accompanist.APPCOMPAT_THEME)
|
implementation(Accompanist.APPCOMPAT_THEME)
|
||||||
|
|
||||||
implementation("io.coil-kt:coil-compose:1.4.0")
|
implementation("io.coil-kt:coil-compose:2.0.0-rc03")
|
||||||
|
|
||||||
implementation(KtorClient.CORE)
|
implementation(KtorClient.CORE)
|
||||||
implementation(KtorClient.OKHTTP)
|
implementation(KtorClient.OKHTTP)
|
||||||
@@ -136,8 +138,6 @@ dependencies {
|
|||||||
|
|
||||||
implementation("org.jsoup:jsoup:1.14.3")
|
implementation("org.jsoup:jsoup:1.14.3")
|
||||||
|
|
||||||
implementation("ru.noties.markwon:core:3.1.0")
|
|
||||||
|
|
||||||
implementation("xyz.quaver.pupil.sources:core:0.0.1-alpha01-DEV26")
|
implementation("xyz.quaver.pupil.sources:core:0.0.1-alpha01-DEV26")
|
||||||
|
|
||||||
implementation("xyz.quaver:documentfilex:0.7.2")
|
implementation("xyz.quaver:documentfilex:0.7.2")
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
/*
|
|
||||||
* Pupil, Hitomi.la viewer for Android
|
|
||||||
* Copyright (C) 2021 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 <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package xyz.quaver.pupil
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.junit.Assert.*
|
|
||||||
import xyz.quaver.pupil.sources.isSourceFeatureEnabled
|
|
||||||
import xyz.quaver.pupil.sources.loadSource
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class SourceLoaderInstrumentedTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun getPackages() {
|
|
||||||
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
|
||||||
val application: Application = appContext.applicationContext as Application
|
|
||||||
|
|
||||||
val packageManager = appContext.packageManager
|
|
||||||
|
|
||||||
val packages = packageManager.getInstalledPackages(
|
|
||||||
PackageManager.GET_CONFIGURATIONS or
|
|
||||||
PackageManager.GET_META_DATA
|
|
||||||
)
|
|
||||||
|
|
||||||
val sources = packages.filter { it.isSourceFeatureEnabled }
|
|
||||||
|
|
||||||
assertEquals(1, sources.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -83,6 +83,10 @@ private val sourceSelectorScreens = listOf(
|
|||||||
SourceSelectorScreen.Explore
|
SourceSelectorScreen.Explore
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val RemoteSourceInfo.apkUrl: String
|
||||||
|
get() = "https://github.com/tom5079/PupilSources/releases/download/$name-$version/$projectName-release.apk"
|
||||||
|
|
||||||
|
|
||||||
class DownloadApkActionState(override val di: DI) : DIAware {
|
class DownloadApkActionState(override val di: DI) : DIAware {
|
||||||
private val app: Application by instance()
|
private val app: Application by instance()
|
||||||
private val client: PupilHttpClient by instance()
|
private val client: PupilHttpClient by instance()
|
||||||
@@ -90,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, 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,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 ->
|
||||||
@@ -203,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")
|
||||||
@@ -263,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")
|
||||||
@@ -279,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(
|
||||||
|
|||||||
95
app/src/main/java/xyz/quaver/pupil/ui/UpdateDialog.kt
Normal file
95
app/src/main/java/xyz/quaver/pupil/ui/UpdateDialog.kt
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/*
|
||||||
|
* 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 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
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(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
|
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.*
|
||||||
@@ -32,7 +33,9 @@ import kotlinx.coroutines.flow.flow
|
|||||||
import kotlinx.coroutines.flow.flowOn
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.*
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class RemoteSourceInfo(
|
data class RemoteSourceInfo(
|
||||||
@@ -41,6 +44,18 @@ data class RemoteSourceInfo(
|
|||||||
val version: String
|
val version: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class Release(
|
||||||
|
val version: String,
|
||||||
|
val apkUrl: String,
|
||||||
|
val releaseNotes: Map<Locale, String>
|
||||||
|
)
|
||||||
|
|
||||||
|
private val localeMap = mapOf(
|
||||||
|
"한국어" to Locale.KOREAN,
|
||||||
|
"日本語" to Locale.JAPANESE,
|
||||||
|
"English" to Locale.ENGLISH
|
||||||
|
)
|
||||||
|
|
||||||
class PupilHttpClient(engine: HttpClientEngine) {
|
class PupilHttpClient(engine: HttpClientEngine) {
|
||||||
private val httpClient = HttpClient(engine) {
|
private val httpClient = HttpClient(engine) {
|
||||||
install(ContentNegotiation) {
|
install(ContentNegotiation) {
|
||||||
@@ -48,33 +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(sourceInfo: RemoteSourceInfo, dest: File) = flow {
|
/**
|
||||||
val url =
|
* Downloads specific file from :url to :dest.
|
||||||
"https://github.com/tom5079/PupilSources/releases/download/${sourceInfo.name}-${sourceInfo.version}/${sourceInfo.projectName}-release.apk"
|
* 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
|
||||||
|
|
||||||
httpClient.prepareGet(url).execute { response ->
|
dest.outputStream().use { outputStream ->
|
||||||
val channel = response.bodyAsChannel()
|
while (!channel.isClosedForRead) {
|
||||||
val contentLength = response.contentLength() ?: -1
|
val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
|
||||||
var readBytes = 0f
|
while (!packet.isEmpty) {
|
||||||
|
val bytes = packet.readBytes()
|
||||||
|
outputStream.write(bytes)
|
||||||
|
|
||||||
dest.outputStream().use { outputStream ->
|
readBytes += bytes.size
|
||||||
while (!channel.isClosedForRead) {
|
emit(readBytes / contentLength)
|
||||||
val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
|
}
|
||||||
while (!packet.isEmpty) {
|
|
||||||
val bytes = packet.readBytes()
|
|
||||||
outputStream.write(bytes)
|
|
||||||
|
|
||||||
readBytes += bytes.size
|
|
||||||
emit(readBytes / contentLength)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
emit(Float.POSITIVE_INFINITY)
|
emit(Float.POSITIVE_INFINITY)
|
||||||
|
}.onFailure {
|
||||||
|
emit(Float.NEGATIVE_INFINITY)
|
||||||
|
}
|
||||||
}.flowOn(Dispatchers.IO)
|
}.flowOn(Dispatchers.IO)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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 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()
|
||||||
|
}
|
||||||
|
locale = newLocale
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locale != null) stringBuilder.appendLine(line)
|
||||||
|
}
|
||||||
|
put(locale!!, stringBuilder.deleteCharAt(stringBuilder.length-1).toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
Release(version, apkUrl, releaseNotes)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -31,6 +31,7 @@ import org.junit.Test
|
|||||||
import xyz.quaver.pupil.util.PupilHttpClient
|
import xyz.quaver.pupil.util.PupilHttpClient
|
||||||
import xyz.quaver.pupil.util.RemoteSourceInfo
|
import xyz.quaver.pupil.util.RemoteSourceInfo
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.*
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@@ -65,9 +66,48 @@ class PupilHttpClientTest {
|
|||||||
|
|
||||||
val client = PupilHttpClient(mockEngine)
|
val client = PupilHttpClient(mockEngine)
|
||||||
|
|
||||||
client.downloadApk(RemoteSourceInfo("", "", ""), tempFile).collect()
|
client.downloadFile("http://a/", tempFile).collect()
|
||||||
|
|
||||||
assertArrayEquals(expected, tempFile.readBytes())
|
assertArrayEquals(expected, tempFile.readBytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
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 expectedReleaseNotes = mapOf(
|
||||||
|
Locale.KOREAN to """
|
||||||
|
* 가끔씩 무한로딩 걸리는 현상 수정
|
||||||
|
* 백업시 즐겨찾기 태그도 백업되게 수정
|
||||||
|
* 이전 안드로이드에서 앱이 튕기는 오류 수정
|
||||||
|
""".trimIndent(),
|
||||||
|
Locale.JAPANESE to """
|
||||||
|
* 稀に接続不可になるバグを修正
|
||||||
|
* お気に入りタグを含むようバックアップ機能を修正
|
||||||
|
* 旧バージョンのアンドロイドでアプリがクラッシュするバグを解決
|
||||||
|
""".trimIndent(),
|
||||||
|
Locale.ENGLISH to """
|
||||||
|
* Fixed occasional outage
|
||||||
|
* Updated backup/restore feature to include favorite tags
|
||||||
|
* Fixed app crashing on older Androids
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
|
||||||
|
val mockEngine = MockEngine { _ ->
|
||||||
|
val response = javaClass.getResource("/releases.json")!!.readText()
|
||||||
|
respond(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
val client = PupilHttpClient(mockEngine)
|
||||||
|
|
||||||
|
val release = client.latestRelease()!!
|
||||||
|
|
||||||
|
assertEquals(expectedVersion, release.version)
|
||||||
|
assertEquals(expectedApkUrl, release.apkUrl)
|
||||||
|
|
||||||
|
println(expectedReleaseNotes)
|
||||||
|
println(release.releaseNotes)
|
||||||
|
assertEquals(expectedReleaseNotes, release.releaseNotes)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
2189
app/src/test/resources/releases.json
Normal file
2189
app/src/test/resources/releases.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,7 @@ object Versions {
|
|||||||
const val COROUTINE = "1.6.1"
|
const val COROUTINE = "1.6.1"
|
||||||
const val SERIALIZATION = "1.3.2"
|
const val SERIALIZATION = "1.3.2"
|
||||||
|
|
||||||
const val JETPACK_COMPOSE = "1.1.1"
|
const val JETPACK_COMPOSE = "1.2.0-alpha07"
|
||||||
const val ACCOMPANIST = "0.23.1"
|
const val ACCOMPANIST = "0.23.1"
|
||||||
|
|
||||||
const val KTOR = "2.0.0"
|
const val KTOR = "2.0.0"
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user