diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 733595ef..4e3b5f8f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") } diff --git a/app/src/main/java/xyz/quaver/pupil/Pupil.kt b/app/src/main/java/xyz/quaver/pupil/Pupil.kt index 2e4b6715..0bc2a508 100644 --- a/app/src/main/java/xyz/quaver/pupil/Pupil.kt +++ b/app/src/main/java/xyz/quaver/pupil/Pupil.kt @@ -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() { diff --git a/app/src/main/java/xyz/quaver/pupil/sources/SourceLoader.kt b/app/src/main/java/xyz/quaver/pupil/sources/LocalSources.kt similarity index 81% rename from app/src/main/java/xyz/quaver/pupil/sources/SourceLoader.kt rename to app/src/main/java/xyz/quaver/pupil/sources/LocalSources.kt index aca56643..eaafcccf 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/SourceLoader.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/LocalSources.kt @@ -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> = 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 { +private fun loadSource(context: Context, packageInfo: PackageInfo): List { val packageManager = context.packageManager val applicationInfo = packageInfo.applicationInfo @@ -84,19 +92,7 @@ fun loadSource(context: Context, packageInfo: PackageInfo): List { private val sourceCacheMutex = Mutex() private val sourceCache = mutableMapOf() -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 { +private fun loadSourceList(context: Context): List { val packageManager = context.packageManager val packages = packageManager.getInstalledPackages( @@ -109,19 +105,4 @@ fun updateSources(context: Context): List { else emptyList() } -} - -@Composable -fun rememberSources(): State> { - val sources = remember { mutableStateOf>(emptyList()) } - val context = LocalContext.current - - LaunchedEffect(Unit) { - while (true) { - sources.value = updateSources(context) - delay(1000) - } - } - - return sources } \ No newline at end of file diff --git a/app/src/test/java/xyz/quaver/pupil/ExampleUnitTest.kt b/app/src/main/java/xyz/quaver/pupil/sources/RemoteSources.kt similarity index 55% rename from app/src/test/java/xyz/quaver/pupil/ExampleUnitTest.kt rename to app/src/main/java/xyz/quaver/pupil/sources/RemoteSources.kt index 51190cb2..0520fb61 100644 --- a/app/src/test/java/xyz/quaver/pupil/ExampleUnitTest.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/RemoteSources.kt @@ -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 . */ -@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?>(null) { + while (true) { + value = client.getRemoteSourceList() + delay(1000) } - -} +} \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/migrate/Migrate.kt b/app/src/main/java/xyz/quaver/pupil/sources/common.kt similarity index 69% rename from app/src/main/java/xyz/quaver/pupil/migrate/Migrate.kt rename to app/src/main/java/xyz/quaver/pupil/sources/common.kt index 5ef1cc0b..4f7b31d8 100644 --- a/app/src/main/java/xyz/quaver/pupil/migrate/Migrate.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/common.kt @@ -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 . */ -package xyz.quaver.pupil.migrate +package xyz.quaver.pupil.sources -class Migrate { +import android.graphics.drawable.Drawable - fun migrate() { - - } - -} \ No newline at end of file +data class SourceEntry( + val packageName: String, + val packagePath: String, + val sourceName: String, + val sourcePath: String, + val sourceDir: String, + val icon: Drawable, + val version: String +) diff --git a/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt b/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt index eb4791c0..d5a3c8c7 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt @@ -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) diff --git a/app/src/main/java/xyz/quaver/pupil/ui/SourceSelector.kt b/app/src/main/java/xyz/quaver/pupil/ui/SourceSelector.kt index 15b73ae8..54b1579a 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/SourceSelector.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/SourceSelector.kt @@ -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(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 rememberDownloadApkActionState(di: DI = localDI()) = remember { DownloadApkActionState(di) } + @Composable -fun SourceListItem(icon: Painter, name: String, version: String, actions: @Composable () -> Unit = { }) { +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 + + val localSourceList by rememberLocalSourceList() + val remoteSourceList by rememberRemoteSourceList() - if (sources.isEmpty()) { + if (localSourceList.isEmpty()) { Box(Modifier.fillMaxSize()) { Column( Modifier.align(Alignment.Center), @@ -136,16 +182,38 @@ fun Local(onSource: (SourceEntry) -> Unit) { } } else { LazyColumn { - items(sources) { source -> + items(localSourceList) { source -> + val actionState = rememberDownloadApkActionState() + SourceListItem( - rememberDrawablePainter(source.icon), + icon = { modifier -> + Image( + rememberDrawablePainter(source.icon), + contentDescription = "source icon", + modifier = modifier + ) + }, source.sourceName, source.version ) { - TextButton( - onClick = { onSource(source) } - ) { - Text("GO") + 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) } + ) { + Text("GO") + } + } } } } @@ -153,39 +221,20 @@ 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() } - val context = LocalContext.current val coroutineScope = rememberCoroutineScope() - val remoteSources by produceState?>(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 - else Icons.Outlined.Info, + 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() } } diff --git a/app/src/main/java/xyz/quaver/pupil/util/ApkDownloadManager.kt b/app/src/main/java/xyz/quaver/pupil/util/PupilHttpClient.kt similarity index 55% rename from app/src/main/java/xyz/quaver/pupil/util/ApkDownloadManager.kt rename to app/src/main/java/xyz/quaver/pupil/util/PupilHttpClient.kt index 3ee9a8a3..e063e14a 100644 --- a/app/src/main/java/xyz/quaver/pupil/util/ApkDownloadManager.kt +++ b/app/src/main/java/xyz/quaver/pupil/util/PupilHttpClient.kt @@ -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 . + * along with this program. If not, see . */ 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 = 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) } \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/migrate/Migrate001.kt b/app/src/main/java/xyz/quaver/pupil/util/util.kt similarity index 52% rename from app/src/main/java/xyz/quaver/pupil/migrate/Migrate001.kt rename to app/src/main/java/xyz/quaver/pupil/util/util.kt index aabdc265..34feaf8c 100644 --- a/app/src/main/java/xyz/quaver/pupil/migrate/Migrate001.kt +++ b/app/src/main/java/xyz/quaver/pupil/util/util.kt @@ -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 . */ -package xyz.quaver.pupil.migrate +package xyz.quaver.pupil.util -class Migrate001 { +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) } \ No newline at end of file diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index 99ad1e58..a015d632 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -19,5 +19,5 @@ - + \ No newline at end of file diff --git a/app/src/test/java/xyz/quaver/pupil/PupilHttpClientTest.kt b/app/src/test/java/xyz/quaver/pupil/PupilHttpClientTest.kt new file mode 100644 index 00000000..2e364156 --- /dev/null +++ b/app/src/test/java/xyz/quaver/pupil/PupilHttpClientTest.kt @@ -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 . + */ + +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()) + } + +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index fa96b004..866f366c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 diff --git a/buildSrc/src/main/kotlin/Config.kt b/buildSrc/src/main/kotlin/Config.kt index e3602e63..ae3d92df 100644 --- a/buildSrc/src/main/kotlin/Config.kt +++ b/buildSrc/src/main/kotlin/Config.kt @@ -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 { @@ -44,4 +54,13 @@ object Accompanist { const val INSETS_UI = "com.google.accompanist:accompanist-insets-ui:${Versions.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}" } \ No newline at end of file