Implement source update
Refactor codes for testability
This commit is contained in:
@@ -86,8 +86,8 @@ android {
|
||||
dependencies {
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar"))))
|
||||
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.1")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2")
|
||||
implementation(Kotlin.SERIALIZATION)
|
||||
implementation(Kotlin.COROUTINE)
|
||||
|
||||
implementation("androidx.activity:activity-compose:1.4.0")
|
||||
implementation("androidx.navigation:navigation-compose:2.4.2")
|
||||
@@ -110,10 +110,10 @@ dependencies {
|
||||
|
||||
implementation("io.coil-kt:coil-compose:1.4.0")
|
||||
|
||||
implementation("io.ktor:ktor-client-core:2.0.0")
|
||||
implementation("io.ktor:ktor-client-okhttp:2.0.0")
|
||||
implementation("io.ktor:ktor-client-content-negotiation:2.0.0")
|
||||
implementation("io.ktor:ktor-serialization-kotlinx-json:2.0.0")
|
||||
implementation(KtorClient.CORE)
|
||||
implementation(KtorClient.OKHTTP)
|
||||
implementation(KtorClient.CONTENT_NEGOTIATION)
|
||||
implementation(KtorClient.SERIALIZATION)
|
||||
|
||||
implementation("androidx.room:room-runtime:2.4.2")
|
||||
annotationProcessor("androidx.room:room-compiler:2.4.2")
|
||||
@@ -144,15 +144,18 @@ dependencies {
|
||||
implementation("xyz.quaver:subsampledimage:0.0.1-alpha19-SNAPSHOT")
|
||||
|
||||
implementation("org.kodein.log:kodein-log:0.12.0")
|
||||
// debugImplementation("com.squareup.leakcanary:leakcanary-android:2.8.1")
|
||||
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.8.1")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.mockito:mockito-inline:4.4.0")
|
||||
testImplementation(KtorClient.TEST)
|
||||
testImplementation(Kotlin.COROUTINE_TEST)
|
||||
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.3")
|
||||
androidTestImplementation("androidx.test:rules:1.4.0")
|
||||
androidTestImplementation("androidx.test:runner:1.4.0")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
|
||||
androidTestImplementation(KtorClient.TEST)
|
||||
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.1.1")
|
||||
}
|
||||
|
||||
@@ -30,11 +30,12 @@ import com.google.android.gms.security.ProviderInstaller
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.okhttp.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import org.kodein.di.*
|
||||
import org.kodein.di.android.x.androidXModule
|
||||
import xyz.quaver.pupil.sources.core.NetworkCache
|
||||
import xyz.quaver.pupil.sources.core.settingsDataStore
|
||||
import xyz.quaver.pupil.util.ApkDownloadManager
|
||||
import xyz.quaver.pupil.util.PupilHttpClient
|
||||
|
||||
class Pupil : Application(), DIAware {
|
||||
|
||||
@@ -42,15 +43,10 @@ class Pupil : Application(), DIAware {
|
||||
import(androidXModule(this@Pupil))
|
||||
|
||||
bind { singleton { NetworkCache(this@Pupil) } }
|
||||
bindSingleton { ApkDownloadManager(this@Pupil, instance()) }
|
||||
|
||||
bindSingleton { settingsDataStore }
|
||||
|
||||
bind { singleton {
|
||||
HttpClient(OkHttp) {
|
||||
install(ContentNegotiation)
|
||||
}
|
||||
} }
|
||||
bind { singleton { PupilHttpClient(OkHttp.create()) } }
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
|
||||
@@ -22,7 +22,6 @@ import android.app.Application
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import dalvik.system.PathClassLoader
|
||||
@@ -31,26 +30,35 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import xyz.quaver.pupil.sources.core.Source
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@Composable
|
||||
fun rememberLocalSourceList(context: Context = LocalContext.current): State<List<SourceEntry>> = produceState(emptyList()) {
|
||||
while (true) {
|
||||
value = loadSourceList(context)
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadSource(context: Context, sourceEntry: SourceEntry): Source = coroutineScope {
|
||||
sourceCacheMutex.withLock {
|
||||
sourceCache[sourceEntry.packageName] ?: run {
|
||||
val classLoader = PathClassLoader(sourceEntry.sourceDir, null, context.classLoader)
|
||||
|
||||
Class.forName("${sourceEntry.packagePath}${sourceEntry.sourcePath}", false, classLoader)
|
||||
.getConstructor(Application::class.java)
|
||||
.newInstance(context.applicationContext) as Source
|
||||
}.also { sourceCache[sourceEntry.packageName] = it }
|
||||
}
|
||||
}
|
||||
|
||||
private const val SOURCES_FEATURE = "pupil.sources"
|
||||
private const val SOURCES_PACKAGE_PREFIX = "xyz.quaver.pupil.sources"
|
||||
private const val SOURCES_PATH = "pupil.sources.path"
|
||||
|
||||
data class SourceEntry(
|
||||
val packageName: String,
|
||||
val packagePath: String,
|
||||
val sourceName: String,
|
||||
val sourcePath: String,
|
||||
val sourceDir: String,
|
||||
val icon: Drawable,
|
||||
val version: String
|
||||
)
|
||||
|
||||
val PackageInfo.isSourceFeatureEnabled
|
||||
private val PackageInfo.isSourceFeatureEnabled
|
||||
get() = this.reqFeatures.orEmpty().any { it.name == SOURCES_FEATURE }
|
||||
|
||||
fun loadSource(context: Context, packageInfo: PackageInfo): List<SourceEntry> {
|
||||
private fun loadSource(context: Context, packageInfo: PackageInfo): List<SourceEntry> {
|
||||
val packageManager = context.packageManager
|
||||
|
||||
val applicationInfo = packageInfo.applicationInfo
|
||||
@@ -84,19 +92,7 @@ fun loadSource(context: Context, packageInfo: PackageInfo): List<SourceEntry> {
|
||||
private val sourceCacheMutex = Mutex()
|
||||
private val sourceCache = mutableMapOf<String, Source>()
|
||||
|
||||
suspend fun loadSource(context: Context, sourceEntry: SourceEntry): Source = coroutineScope {
|
||||
sourceCacheMutex.withLock {
|
||||
sourceCache[sourceEntry.packageName] ?: run {
|
||||
val classLoader = PathClassLoader(sourceEntry.sourceDir, null, context.classLoader)
|
||||
|
||||
Class.forName("${sourceEntry.packagePath}${sourceEntry.sourcePath}", false, classLoader)
|
||||
.getConstructor(Application::class.java)
|
||||
.newInstance(context.applicationContext) as Source
|
||||
}.also { sourceCache[sourceEntry.packageName] = it }
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSources(context: Context): List<SourceEntry> {
|
||||
private fun loadSourceList(context: Context): List<SourceEntry> {
|
||||
val packageManager = context.packageManager
|
||||
|
||||
val packages = packageManager.getInstalledPackages(
|
||||
@@ -110,18 +106,3 @@ fun updateSources(context: Context): List<SourceEntry> {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberSources(): State<List<SourceEntry>> {
|
||||
val sources = remember { mutableStateOf<List<SourceEntry>>(emptyList()) }
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
sources.value = updateSources(context)
|
||||
delay(1000)
|
||||
}
|
||||
}
|
||||
|
||||
return sources
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2019 tom5079
|
||||
* 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
|
||||
@@ -16,26 +16,21 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
@file:Suppress("UNUSED_VARIABLE", "IncorrectScope")
|
||||
package xyz.quaver.pupil.sources
|
||||
|
||||
package xyz.quaver.pupil
|
||||
import androidx.compose.runtime.*
|
||||
import kotlinx.coroutines.delay
|
||||
import org.kodein.di.compose.localDI
|
||||
import org.kodein.di.compose.rememberInstance
|
||||
import org.kodein.di.direct
|
||||
import org.kodein.di.instance
|
||||
import xyz.quaver.pupil.util.PupilHttpClient
|
||||
import xyz.quaver.pupil.util.RemoteSourceInfo
|
||||
|
||||
import io.ktor.client.*
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Test
|
||||
import xyz.quaver.pupil.sources.manatoki.getItem
|
||||
import org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* See [testing documentation](http://d.android.com/tools/testing).
|
||||
*/
|
||||
|
||||
class ExampleUnitTest {
|
||||
|
||||
@Test
|
||||
fun test() {
|
||||
@Composable
|
||||
fun rememberRemoteSourceList(client: PupilHttpClient = localDI().direct.instance()) = produceState<Map<String, RemoteSourceInfo>?>(null) {
|
||||
while (true) {
|
||||
value = client.getRemoteSourceList()
|
||||
delay(1000)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
* 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
|
||||
@@ -16,12 +16,16 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.migrate
|
||||
package xyz.quaver.pupil.sources
|
||||
|
||||
class Migrate {
|
||||
import android.graphics.drawable.Drawable
|
||||
|
||||
fun migrate() {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
data class SourceEntry(
|
||||
val packageName: String,
|
||||
val packagePath: String,
|
||||
val sourceName: String,
|
||||
val sourcePath: String,
|
||||
val sourceDir: String,
|
||||
val icon: Drawable,
|
||||
val version: String
|
||||
)
|
||||
@@ -52,8 +52,6 @@ import xyz.quaver.pupil.ui.theme.PupilTheme
|
||||
class MainActivity : ComponentActivity(), DIAware {
|
||||
override val di by closestDI()
|
||||
|
||||
private val logger = newLogger(LoggerFactory.default)
|
||||
|
||||
@SuppressLint("UnusedCrossfadeTargetStateParameter")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
package xyz.quaver.pupil.ui
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.provider.Settings
|
||||
@@ -34,7 +35,6 @@ import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.capitalize
|
||||
@@ -55,20 +55,23 @@ 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 io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.request.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
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.rememberSources
|
||||
import xyz.quaver.pupil.util.ApkDownloadManager
|
||||
import xyz.quaver.pupil.sources.rememberLocalSourceList
|
||||
import xyz.quaver.pupil.sources.rememberRemoteSourceList
|
||||
import xyz.quaver.pupil.util.PupilHttpClient
|
||||
import xyz.quaver.pupil.util.RemoteSourceInfo
|
||||
import xyz.quaver.pupil.util.launchApkInstaller
|
||||
import java.io.File
|
||||
import kotlin.collections.associateBy
|
||||
import kotlin.collections.contains
|
||||
import kotlin.collections.forEach
|
||||
import kotlin.collections.listOf
|
||||
import kotlin.collections.orEmpty
|
||||
|
||||
private sealed class SourceSelectorScreen(val route: String, val icon: ImageVector) {
|
||||
object Local: SourceSelectorScreen("local", Icons.Default.DownloadDone)
|
||||
@@ -80,8 +83,53 @@ private val sourceSelectorScreens = listOf(
|
||||
SourceSelectorScreen.Explore
|
||||
)
|
||||
|
||||
class DownloadApkActionState(override val di: DI) : DIAware {
|
||||
private val app: Application by instance()
|
||||
private val client: PupilHttpClient by instance()
|
||||
|
||||
var progress by mutableStateOf<Float?>(null)
|
||||
private set
|
||||
|
||||
suspend fun download(sourceInfo: RemoteSourceInfo): File {
|
||||
val file = File(app.cacheDir, "apks/${sourceInfo.name}-${sourceInfo.version}.apk").also {
|
||||
it.parentFile?.mkdirs()
|
||||
}
|
||||
|
||||
client.downloadApk(sourceInfo, file).collect { progress = it }
|
||||
|
||||
require(progress == Float.POSITIVE_INFINITY)
|
||||
|
||||
progress = null
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SourceListItem(icon: Painter, name: String, version: String, actions: @Composable () -> Unit = { }) {
|
||||
fun rememberDownloadApkActionState(di: DI = localDI()) = remember { DownloadApkActionState(di) }
|
||||
|
||||
@Composable
|
||||
fun DownloadApkAction(
|
||||
state: DownloadApkActionState = rememberDownloadApkActionState(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
state.progress?.let { progress ->
|
||||
Box(
|
||||
Modifier.padding(12.dp, 0.dp)
|
||||
) {
|
||||
when {
|
||||
progress.isFinite() ->
|
||||
CircularProgressIndicator(progress, modifier = Modifier.size(24.dp))
|
||||
else ->
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
} ?: content()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SourceListItem(icon: @Composable (Modifier) -> Unit = { }, name: String, version: String, actions: @Composable () -> Unit = { }) {
|
||||
Card(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
elevation = 4.dp
|
||||
@@ -91,18 +139,12 @@ fun SourceListItem(icon: Painter, name: String, version: String, actions: @Compo
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Image(
|
||||
icon,
|
||||
contentDescription = "source icon",
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
icon(Modifier.size(48.dp))
|
||||
|
||||
Column(
|
||||
Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
name.capitalize(Locale.current)
|
||||
)
|
||||
Text(name.capitalize(Locale.current))
|
||||
|
||||
CompositionLocalProvider(LocalContentAlpha provides 0.5f) {
|
||||
Text(
|
||||
@@ -119,9 +161,13 @@ fun SourceListItem(icon: Painter, name: String, version: String, actions: @Compo
|
||||
|
||||
@Composable
|
||||
fun Local(onSource: (SourceEntry) -> Unit) {
|
||||
val sources by rememberSources()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
if (sources.isEmpty()) {
|
||||
val localSourceList by rememberLocalSourceList()
|
||||
val remoteSourceList by rememberRemoteSourceList()
|
||||
|
||||
if (localSourceList.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
Modifier.align(Alignment.Center),
|
||||
@@ -136,12 +182,32 @@ fun Local(onSource: (SourceEntry) -> Unit) {
|
||||
}
|
||||
} else {
|
||||
LazyColumn {
|
||||
items(sources) { source ->
|
||||
items(localSourceList) { source ->
|
||||
val actionState = rememberDownloadApkActionState()
|
||||
|
||||
SourceListItem(
|
||||
icon = { modifier ->
|
||||
Image(
|
||||
rememberDrawablePainter(source.icon),
|
||||
contentDescription = "source icon",
|
||||
modifier = modifier
|
||||
)
|
||||
},
|
||||
source.sourceName,
|
||||
source.version
|
||||
) {
|
||||
DownloadApkAction(actionState) {
|
||||
val remoteSource = remoteSourceList?.get(source.packageName)
|
||||
if (remoteSource != null && remoteSource.version != source.version) {
|
||||
TextButton(onClick = {
|
||||
coroutineScope.launch {
|
||||
val file = actionState.download(remoteSource)
|
||||
context.launchApkInstaller(file)
|
||||
}
|
||||
}) {
|
||||
Text("UPDATE")
|
||||
}
|
||||
} else {
|
||||
TextButton(
|
||||
onClick = { onSource(source) }
|
||||
) {
|
||||
@@ -152,40 +218,23 @@ fun Local(onSource: (SourceEntry) -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class RemoteSourceInfo(
|
||||
val projectName: String,
|
||||
val name: String,
|
||||
val version: String
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Explore() {
|
||||
val sources by rememberSources()
|
||||
val localSourceList by rememberLocalSourceList()
|
||||
val localSources by derivedStateOf {
|
||||
sources.map {
|
||||
it.packageName to it
|
||||
}.toMap()
|
||||
localSourceList.associateBy {
|
||||
it.packageName
|
||||
}
|
||||
}
|
||||
|
||||
val client: HttpClient by rememberInstance()
|
||||
|
||||
val downloadManager: ApkDownloadManager by rememberInstance()
|
||||
val progresses = remember { mutableStateMapOf<String, Float>() }
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val remoteSources by produceState<Map<String, RemoteSourceInfo>?>(null) {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
value = withContext(Dispatchers.IO) {
|
||||
client.get("https://raw.githubusercontent.com/tom5079/PupilSources/master/versions.json").body()
|
||||
}
|
||||
}
|
||||
}
|
||||
val remoteSources by rememberRemoteSourceList()
|
||||
|
||||
Box(
|
||||
Modifier.fillMaxSize()
|
||||
@@ -194,50 +243,40 @@ fun Explore() {
|
||||
CircularProgressIndicator(Modifier.align(Alignment.Center))
|
||||
else
|
||||
LazyColumn {
|
||||
items(remoteSources?.values?.toList() ?: emptyList()) { source ->
|
||||
items(remoteSources?.values?.toList().orEmpty()) { sourceInfo ->
|
||||
val actionState = rememberDownloadApkActionState()
|
||||
|
||||
SourceListItem(
|
||||
rememberImagePainter("https://raw.githubusercontent.com/tom5079/PupilSources/master/${source.projectName}/src/main/res/mipmap-xxxhdpi/ic_launcher.png"),
|
||||
source.name,
|
||||
source.version
|
||||
icon = { modifier ->
|
||||
Image(
|
||||
rememberImagePainter("https://raw.githubusercontent.com/tom5079/PupilSources/master/${sourceInfo.projectName}/src/main/res/mipmap-xxxhdpi/ic_launcher.png"),
|
||||
contentDescription = "source icon",
|
||||
modifier = modifier
|
||||
)
|
||||
},
|
||||
sourceInfo.name,
|
||||
sourceInfo.version
|
||||
) {
|
||||
if (source.name !in progresses)
|
||||
DownloadApkAction(actionState) {
|
||||
IconButton(onClick = {
|
||||
if (source.name in localSources) {
|
||||
if (sourceInfo.name in localSources) {
|
||||
context.startActivity(
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", localSources[source.name]!!.packagePath, null)
|
||||
Uri.fromParts("package", localSources[sourceInfo.name]!!.packagePath, null)
|
||||
)
|
||||
)
|
||||
} else coroutineScope.launch {
|
||||
progresses[source.name] = 0f
|
||||
downloadManager.download(source.projectName, source.name, source.version)
|
||||
.onCompletion {
|
||||
progresses.remove(source.name)
|
||||
}.collectLatest {
|
||||
progresses[source.name] = it
|
||||
}
|
||||
val file = actionState.download(sourceInfo)
|
||||
context.launchApkInstaller(file)
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
if (source.name !in localSources) Icons.Default.Download
|
||||
if (sourceInfo.name !in localSources) Icons.Default.Download
|
||||
else Icons.Outlined.Info,
|
||||
contentDescription = "download"
|
||||
)
|
||||
}
|
||||
else {
|
||||
val progress = progresses[source.name]
|
||||
|
||||
Box(
|
||||
Modifier.padding(12.dp, 0.dp)
|
||||
) {
|
||||
when {
|
||||
progress?.isFinite() == true ->
|
||||
CircularProgressIndicator(progress, modifier = Modifier.size(24.dp))
|
||||
else ->
|
||||
CircularProgressIndicator(modifier = Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -284,7 +323,9 @@ fun SourceSelector(onSource: (SourceEntry) -> Unit) {
|
||||
}
|
||||
}
|
||||
) { contentPadding ->
|
||||
NavHost(bottomNavController, startDestination = "local", modifier = Modifier.systemBarsPadding(top = false, bottom = false).padding(contentPadding)) {
|
||||
NavHost(bottomNavController, startDestination = "local", modifier = Modifier
|
||||
.systemBarsPadding(top = false, bottom = false)
|
||||
.padding(contentPadding)) {
|
||||
composable(SourceSelectorScreen.Local.route) { Local(onSource) }
|
||||
composable(SourceSelectorScreen.Explore.route) { Explore() }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
* 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
|
||||
@@ -13,41 +13,55 @@
|
||||
* 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/>.
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.content.FileProvider
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
import io.ktor.client.engine.*
|
||||
import io.ktor.client.plugins.contentnegotiation.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import io.ktor.utils.io.core.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
import kotlin.io.use
|
||||
|
||||
class ApkDownloadManager(private val context: Context, private val client: HttpClient) {
|
||||
fun download(projectName: String, sourceName: String, version: String) = flow {
|
||||
val url = "https://github.com/tom5079/PupilSources/releases/download/$sourceName-$version/$projectName-release.apk"
|
||||
@Serializable
|
||||
data class RemoteSourceInfo(
|
||||
val projectName: String,
|
||||
val name: String,
|
||||
val version: String
|
||||
)
|
||||
|
||||
val file = File(context.externalCacheDir, "apks/$sourceName-$version.apk").also {
|
||||
it.parentFile?.mkdir()
|
||||
it.delete()
|
||||
class PupilHttpClient(engine: HttpClientEngine) {
|
||||
private val httpClient = HttpClient(engine) {
|
||||
install(ContentNegotiation) {
|
||||
json()
|
||||
}
|
||||
}
|
||||
|
||||
client.prepareGet(url).execute { response ->
|
||||
suspend fun getRemoteSourceList(): Map<String, RemoteSourceInfo> = withContext(Dispatchers.IO) {
|
||||
httpClient.get("https://tom5079.github.io/PupilSources/versions.json").body()
|
||||
}
|
||||
|
||||
fun downloadApk(sourceInfo: RemoteSourceInfo, dest: File) = flow {
|
||||
val url =
|
||||
"https://github.com/tom5079/PupilSources/releases/download/${sourceInfo.name}-${sourceInfo.version}/${sourceInfo.projectName}-release.apk"
|
||||
|
||||
httpClient.prepareGet(url).execute { response ->
|
||||
val channel = response.bodyAsChannel()
|
||||
val contentLength = response.contentLength() ?: -1
|
||||
var readBytes = 0f
|
||||
|
||||
file.outputStream().use { outputStream ->
|
||||
dest.outputStream().use { outputStream ->
|
||||
while (!channel.isClosedForRead) {
|
||||
val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE.toLong())
|
||||
while (!packet.isEmpty) {
|
||||
@@ -62,20 +76,5 @@ class ApkDownloadManager(private val context: Context, private val client: HttpC
|
||||
}
|
||||
|
||||
emit(Float.POSITIVE_INFINITY)
|
||||
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
setDataAndType(uri, MimeTypeMap.getSingleton().getMimeTypeFromExtension("apk"))
|
||||
}
|
||||
|
||||
context.startActivity(intent)
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/*
|
||||
* Pupil, Hitomi.la viewer for Android
|
||||
* Copyright (C) 2021 tom5079
|
||||
* 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
|
||||
@@ -16,10 +16,23 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package xyz.quaver.pupil.migrate
|
||||
|
||||
class Migrate001 {
|
||||
package xyz.quaver.pupil.util
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.content.FileProvider
|
||||
import java.io.File
|
||||
|
||||
fun Context.launchApkInstaller(file: File) {
|
||||
val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", file)
|
||||
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
setDataAndType(uri, MimeTypeMap.getSingleton().getMimeTypeFromExtension("apk"))
|
||||
}
|
||||
|
||||
startActivity(intent)
|
||||
}
|
||||
@@ -19,5 +19,5 @@
|
||||
|
||||
<paths>
|
||||
<cache-path name="cached_image" path="networkcache/" />
|
||||
<external-cache-path name="apks" path="apks/" />
|
||||
<cache-path name="apks" path="apks/" />
|
||||
</paths>
|
||||
76
app/src/test/java/xyz/quaver/pupil/PupilHttpClientTest.kt
Normal file
76
app/src/test/java/xyz/quaver/pupil/PupilHttpClientTest.kt
Normal file
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import io.ktor.client.engine.mock.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import xyz.quaver.pupil.util.PupilHttpClient
|
||||
import xyz.quaver.pupil.util.RemoteSourceInfo
|
||||
import java.io.File
|
||||
import kotlin.math.exp
|
||||
import kotlin.random.Random
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class PupilHttpClientTest {
|
||||
|
||||
val tempFile = File.createTempFile("pupilhttpclienttest", ".apk").also {
|
||||
it.deleteOnExit()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getRemoteSourceList() = runTest {
|
||||
val expected = buildMap {
|
||||
put("hitomi.la", RemoteSourceInfo("hitomi", "hitomi.la", "0.0.1"))
|
||||
}
|
||||
|
||||
val mockEngine = MockEngine { _ ->
|
||||
respond(Json.encodeToString(expected), headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.contentType))
|
||||
}
|
||||
|
||||
val client = PupilHttpClient(mockEngine)
|
||||
|
||||
assertEquals(expected, client.getRemoteSourceList())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadApk() = runTest {
|
||||
val expected = Random.Default.nextBytes(1000000) // 1MB
|
||||
|
||||
val mockEngine = MockEngine { _ ->
|
||||
respond(expected, headers = headersOf(HttpHeaders.ContentType, "application/vnd.android.package-archive"))
|
||||
}
|
||||
|
||||
val client = PupilHttpClient(mockEngine)
|
||||
|
||||
client.downloadApk(RemoteSourceInfo("", "", ""), tempFile).collect()
|
||||
|
||||
assertArrayEquals(expected, tempFile.readBytes())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -7,9 +7,9 @@ buildscript {
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:7.1.3")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.KOTLIN_VERSION}")
|
||||
classpath("org.jetbrains.kotlin:kotlin-android-extensions:${Versions.KOTLIN_VERSION}")
|
||||
classpath("org.jetbrains.kotlin:kotlin-serialization:${Versions.KOTLIN_VERSION}")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.KOTLIN}")
|
||||
classpath("org.jetbrains.kotlin:kotlin-android-extensions:${Versions.KOTLIN}")
|
||||
classpath("org.jetbrains.kotlin:kotlin-serialization:${Versions.KOTLIN}")
|
||||
classpath("com.google.gms:google-services:4.3.10")
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
// in the individual module build.gradle files
|
||||
|
||||
@@ -20,10 +20,20 @@ const val GROUP_ID = "xyz.quaver"
|
||||
const val VERSION = "6.0.0-alpha02"
|
||||
|
||||
object Versions {
|
||||
const val KOTLIN_VERSION = "1.6.10"
|
||||
const val KOTLIN = "1.6.10"
|
||||
const val COROUTINE = "1.6.1"
|
||||
const val SERIALIZATION = "1.3.2"
|
||||
|
||||
const val JETPACK_COMPOSE = "1.1.1"
|
||||
const val ACCOMPANIST = "0.23.1"
|
||||
|
||||
const val KTOR = "2.0.0"
|
||||
}
|
||||
|
||||
object Kotlin {
|
||||
const val SERIALIZATION = "org.jetbrains.kotlinx:kotlinx-serialization-json:${Versions.SERIALIZATION}"
|
||||
const val COROUTINE = "org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.COROUTINE}"
|
||||
const val COROUTINE_TEST = "org.jetbrains.kotlinx:kotlinx-coroutines-test:${Versions.COROUTINE}"
|
||||
}
|
||||
|
||||
object JetpackCompose {
|
||||
@@ -45,3 +55,12 @@ object Accompanist {
|
||||
const val DRAWABLE_PAINTER = "com.google.accompanist:accompanist-drawablepainter:${Versions.ACCOMPANIST}"
|
||||
const val SYSTEM_UI_CONTROLLER = "com.google.accompanist:accompanist-systemuicontroller:${Versions.ACCOMPANIST}"
|
||||
}
|
||||
|
||||
object KtorClient {
|
||||
const val CORE = "io.ktor:ktor-client-core:${Versions.KTOR}"
|
||||
const val OKHTTP = "io.ktor:ktor-client-okhttp:${Versions.KTOR}"
|
||||
const val CONTENT_NEGOTIATION = "io.ktor:ktor-client-content-negotiation:${Versions.KTOR}"
|
||||
const val SERIALIZATION = "io.ktor:ktor-serialization-kotlinx-json:${Versions.KTOR}"
|
||||
|
||||
const val TEST = "io.ktor:ktor-client-mock:${Versions.KTOR}"
|
||||
}
|
||||
Reference in New Issue
Block a user