Implement source update

Refactor codes for testability
This commit is contained in:
tom5079
2022-04-30 22:48:29 +09:00
parent b6ff956637
commit f3f47d9407
13 changed files with 334 additions and 209 deletions

View File

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

View File

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

View File

@@ -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(
@@ -109,19 +105,4 @@ fun updateSources(context: Context): List<SourceEntry> {
else
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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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