Merge pull request #133 from tom5079/Pupil-129

Pupil-129 [Source] Implement in-app update
This commit is contained in:
tom5079
2022-05-06 12:49:00 +09:00
committed by GitHub
9 changed files with 2465 additions and 87 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)
@@ -108,7 +110,7 @@ dependencies {
implementation(Accompanist.DRAWABLE_PAINTER)
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.OKHTTP)
@@ -136,8 +138,6 @@ dependencies {
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:documentfilex:0.7.2")

View File

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

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
@@ -83,6 +83,10 @@ private val sourceSelectorScreens = listOf(
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 {
private val app: Application 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)
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, 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
}
}
@@ -111,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 ->
@@ -203,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")
@@ -263,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")
@@ -279,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,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")
}
}
}
}
}
}
}

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.*
@@ -32,7 +33,9 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.*
import java.io.File
import java.util.*
@Serializable
data class RemoteSourceInfo(
@@ -41,6 +44,18 @@ data class RemoteSourceInfo(
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) {
private val httpClient = HttpClient(engine) {
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) {
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 =
"https://github.com/tom5079/PupilSources/releases/download/${sourceInfo.name}-${sourceInfo.version}/${sourceInfo.projectName}-release.apk"
/**
* 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
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)
/**
* 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()
}
}

View File

@@ -31,6 +31,7 @@ import org.junit.Test
import xyz.quaver.pupil.util.PupilHttpClient
import xyz.quaver.pupil.util.RemoteSourceInfo
import java.io.File
import java.util.*
import kotlin.random.Random
@OptIn(ExperimentalCoroutinesApi::class)
@@ -65,9 +66,48 @@ class PupilHttpClientTest {
val client = PupilHttpClient(mockEngine)
client.downloadApk(RemoteSourceInfo("", "", ""), tempFile).collect()
client.downloadFile("http://a/", tempFile).collect()
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)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,7 @@ object Versions {
const val COROUTINE = "1.6.1"
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 KTOR = "2.0.0"
@@ -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 {