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.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")

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

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

View File

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

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