feat: Extension repo backend

This commit is contained in:
ziro 2024-01-12 21:08:09 +07:00
parent 7c88f3895a
commit 534642ea57
Signed by: null2264
GPG key ID: BA64F8B60AF3EFB6
12 changed files with 132 additions and 61 deletions

View file

@ -35,8 +35,8 @@ android {
minSdk = AndroidConfig.minSdk minSdk = AndroidConfig.minSdk
targetSdk = AndroidConfig.targetSdk targetSdk = AndroidConfig.targetSdk
applicationId = "eu.kanade.tachiyomi" applicationId = "eu.kanade.tachiyomi"
versionCode = 111 versionCode = 112
versionName = "1.7.4" versionName = "1.7.5"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
multiDexEnabled = true multiDexEnabled = true
@ -179,6 +179,7 @@ dependencies {
implementation(libs.firebase.crashlytics) implementation(libs.firebase.crashlytics)
implementation(androidx.lifecycle.viewmodel) implementation(androidx.lifecycle.viewmodel)
implementation(compose.lifecycle.viewmodel)
implementation(androidx.lifecycle.livedata) implementation(androidx.lifecycle.livedata)
implementation(androidx.lifecycle.common) implementation(androidx.lifecycle.common)
implementation(androidx.lifecycle.process) implementation(androidx.lifecycle.process)

View file

@ -1,10 +1,10 @@
package dev.yokai.presentation.source package dev.yokai.presentation.extension.repo
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import eu.kanade.tachiyomi.ui.base.controller.BaseComposeController import eu.kanade.tachiyomi.ui.base.controller.BaseComposeController
class SourceRepoController : class ExtensionRepoController :
BaseComposeController() { BaseComposeController() {
override fun getTitle(): String { override fun getTitle(): String {
@ -14,7 +14,7 @@ class SourceRepoController :
@Preview @Preview
@Composable @Composable
override fun ScreenContent() { override fun ScreenContent() {
SourceRepoScreen( ExtensionRepoScreen(
title = getTitle(), title = getTitle(),
onBackPress = router::handleBack, onBackPress = router::handleBack,
) )

View file

@ -1,4 +1,4 @@
package dev.yokai.presentation.source package dev.yokai.presentation.extension.repo
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -14,7 +14,7 @@ import dev.yokai.presentation.YokaiScaffold
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
@Composable @Composable
fun SourceRepoScreen( fun ExtensionRepoScreen(
title: String, title: String,
onBackPress: () -> Unit, onBackPress: () -> Unit,
) { ) {

View file

@ -0,0 +1,72 @@
package dev.yokai.presentation.extension.repo
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.yokai.domain.source.SourcePreferences
import eu.kanade.tachiyomi.data.preference.minusAssign
import eu.kanade.tachiyomi.data.preference.plusAssign
import eu.kanade.tachiyomi.util.system.launchIO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import okhttp3.internal.toImmutableList
import uy.kohesive.injekt.injectLazy
class ExtensionRepoViewModel :
ViewModel() {
private val sourcePreferences: SourcePreferences by injectLazy()
private val _repoState: MutableStateFlow<ExtensionRepoState> = MutableStateFlow(ExtensionRepoState.Loading)
val repoState: StateFlow<ExtensionRepoState> = _repoState.asStateFlow()
init {
viewModelScope.launchIO {
getRepo().collectLatest { repos ->
_repoState.value = ExtensionRepoState.Success(repos = repos.toImmutableList())
}
}
}
/*
fun addRepo(url: String): Result {
viewModelScope.launchIO {
if (!url.matches(repoRegex))
return Result.InvalidUrl
sourcePreferences.extensionRepos() += url.substringBeforeLast("/index.min.json")
return Result.Success
}
}
*/
fun deleteRepo(repo: String) {
viewModelScope.launchIO {
sourcePreferences.extensionRepos() -= repo
}
}
fun getRepo() =
sourcePreferences.extensionRepos().changes()
.map { it.sortedWith(String.CASE_INSENSITIVE_ORDER) }
}
sealed class ExtensionRepoState {
@Immutable
data object Loading : ExtensionRepoState()
@Immutable
data class Success(
val repos: List<String>,
) : ExtensionRepoState() {
val isEmpty: Boolean
get() = repos.isEmpty()
}
}
private val repoRegex = """^https://.*/index\.min\.json$""".toRegex()

View file

@ -4,8 +4,11 @@ import android.content.Context
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.os.Build import android.os.Build
import android.os.Parcelable import android.os.Parcelable
import dev.yokai.domain.source.SourcePreferences
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi import eu.kanade.tachiyomi.data.preference.minusAssign
import eu.kanade.tachiyomi.data.preference.plusAssign
import eu.kanade.tachiyomi.extension.api.ExtensionApi
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.extension.model.LoadResult import eu.kanade.tachiyomi.extension.model.LoadResult
@ -20,6 +23,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
@ -44,7 +48,7 @@ class ExtensionManager(
/** /**
* API where all the available extensions can be found. * API where all the available extensions can be found.
*/ */
private val api = ExtensionGithubApi() private val api = ExtensionApi()
/** /**
* The installer which installs, updates and uninstalls the extensions. * The installer which installs, updates and uninstalls the extensions.
@ -435,6 +439,7 @@ class ExtensionManager(
val name: String, val name: String,
val versionCode: Long, val versionCode: Long,
val libVersion: Double, val libVersion: Double,
val repoUrl: String? = null,
) : Parcelable { ) : Parcelable {
constructor(extension: Extension.Available) : this( constructor(extension: Extension.Available) : this(
apkName = extension.apkName, apkName = extension.apkName,
@ -442,6 +447,7 @@ class ExtensionManager(
name = extension.name, name = extension.name,
versionCode = extension.versionCode, versionCode = extension.versionCode,
libVersion = extension.libVersion, libVersion = extension.libVersion,
repoUrl = extension.repoUrl,
) )
} }

View file

@ -24,7 +24,7 @@ import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.updater.AppDownloadInstallJob import eu.kanade.tachiyomi.data.updater.AppDownloadInstallJob
import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi import eu.kanade.tachiyomi.extension.api.ExtensionApi
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.util.ExtensionInstaller import eu.kanade.tachiyomi.extension.util.ExtensionInstaller
import eu.kanade.tachiyomi.extension.util.ExtensionLoader import eu.kanade.tachiyomi.extension.util.ExtensionLoader
@ -44,7 +44,7 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam
override suspend fun doWork(): Result = coroutineScope { override suspend fun doWork(): Result = coroutineScope {
val pendingUpdates = try { val pendingUpdates = try {
ExtensionGithubApi().checkForUpdates(context) ExtensionApi().checkForUpdates(context)
} catch (e: Exception) { } catch (e: Exception) {
return@coroutineScope Result.failure() return@coroutineScope Result.failure()
} }

View file

@ -1,13 +1,14 @@
package eu.kanade.tachiyomi.extension.api package eu.kanade.tachiyomi.extension.api
import android.content.Context import android.content.Context
import dev.yokai.domain.source.SourcePreferences
import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult import eu.kanade.tachiyomi.extension.model.LoadResult
import eu.kanade.tachiyomi.extension.util.ExtensionLoader import eu.kanade.tachiyomi.extension.util.ExtensionLoader
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.system.withIOContext import eu.kanade.tachiyomi.util.system.withIOContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -17,44 +18,19 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
internal class ExtensionGithubApi { internal class ExtensionApi {
private val json: Json by injectLazy() private val json: Json by injectLazy()
private val networkService: NetworkHelper by injectLazy() private val networkService: NetworkHelper by injectLazy()
private val sourcePreferences: SourcePreferences by injectLazy()
private var requiresFallbackSource = false
suspend fun findExtensions(): List<Extension.Available> { suspend fun findExtensions(): List<Extension.Available> {
return withIOContext { return withIOContext {
val githubResponse = if (requiresFallbackSource) { val extensions = sourcePreferences.extensionRepos().get().flatMap { getExtensions(it) }
null
} else {
try {
networkService.client
.newCall(GET("${REPO_URL_PREFIX}index.min.json"))
.await()
} catch (e: Throwable) {
Timber.e(e, "Failed to get extensions from GitHub")
requiresFallbackSource = true
null
}
}
val response = githubResponse ?: run {
networkService.client
.newCall(GET("${FALLBACK_REPO_URL_PREFIX}index.min.json"))
.await()
}
val extensions = with(json) {
response
.parseAs<List<ExtensionJsonObject>>()
.toExtensions()
}
// Sanity check - a small number of extensions probably means something broke // Sanity check - a small number of extensions probably means something broke
// with the repo generator // with the repo generator
if (extensions.size < 100) { if (extensions.size < 50) {
throw Exception() throw Exception()
} }
@ -62,6 +38,25 @@ internal class ExtensionGithubApi {
} }
} }
private suspend fun getExtensions(
repoBaseUrl: String,
): List<Extension.Available> {
return try {
val response = networkService.client
.newCall(GET("$repoBaseUrl/index.min.json"))
.awaitSuccess()
with(json) {
response
.parseAs<List<ExtensionJsonObject>>()
.toExtensions(repoBaseUrl)
}
} catch (e: Throwable) {
Timber.e(e, "Failed to get extensions from $repoBaseUrl")
emptyList()
}
}
suspend fun checkForUpdates(context: Context, prefetchedExtensions: List<Extension.Available>? = null): List<Extension.Available> { suspend fun checkForUpdates(context: Context, prefetchedExtensions: List<Extension.Available>? = null): List<Extension.Available> {
return withIOContext { return withIOContext {
val extensions = prefetchedExtensions ?: findExtensions() val extensions = prefetchedExtensions ?: findExtensions()
@ -89,7 +84,7 @@ internal class ExtensionGithubApi {
} }
} }
private fun List<ExtensionJsonObject>.toExtensions(): List<Extension.Available> { private fun List<ExtensionJsonObject>.toExtensions(repoUrl: String): List<Extension.Available> {
return this return this
.filter { .filter {
val libVersion = it.extractLibVersion() val libVersion = it.extractLibVersion()
@ -108,21 +103,14 @@ internal class ExtensionGithubApi {
hasChangelog = it.hasChangelog == 1, hasChangelog = it.hasChangelog == 1,
sources = it.sources ?: emptyList(), sources = it.sources ?: emptyList(),
apkName = it.apk, apkName = it.apk,
iconUrl = "${getUrlPrefix()}icon/${it.pkg}.png", iconUrl = "${repoUrl}icon/${it.pkg}.png",
repoUrl = repoUrl,
) )
} }
} }
fun getApkUrl(extension: ExtensionManager.ExtensionInfo): String { fun getApkUrl(extension: ExtensionManager.ExtensionInfo): String {
return "${getUrlPrefix()}apk/${extension.apkName}" return "${extension.repoUrl}apk/${extension.apkName}"
}
private fun getUrlPrefix(): String {
return if (requiresFallbackSource) {
FALLBACK_REPO_URL_PREFIX
} else {
REPO_URL_PREFIX
}
} }
private fun ExtensionJsonObject.extractLibVersion(): Double { private fun ExtensionJsonObject.extractLibVersion(): Double {
@ -130,8 +118,10 @@ internal class ExtensionGithubApi {
} }
} }
const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/keiyoushi/extensions/repo/" private const val BASE_URL = "https://raw.githubusercontent.com/"
const val FALLBACK_REPO_URL_PREFIX = "https://gcore.jsdelivr.net/gh/keiyoushi/extensions@repo/" private const val REPO_URL_PREFIX = "${BASE_URL}keiyoushi/extensions/repo/"
private const val FALLBACK_BASE_URL = "https://gcore.jsdelivr.net/gh/"
private const val FALLBACK_REPO_URL_PREFIX = "${FALLBACK_BASE_URL}keiyoushi/extensions@repo/"
@Serializable @Serializable
private data class ExtensionJsonObject( private data class ExtensionJsonObject(

View file

@ -33,6 +33,7 @@ sealed class Extension {
val isObsolete: Boolean = false, val isObsolete: Boolean = false,
val isUnofficial: Boolean = false, val isUnofficial: Boolean = false,
val isShared: Boolean, val isShared: Boolean,
val repoUrl: String? = null,
) : Extension() ) : Extension()
data class Available( data class Available(
@ -48,6 +49,7 @@ sealed class Extension {
val apkName: String, val apkName: String,
val iconUrl: String, val iconUrl: String,
val sources: List<AvailableSource>, val sources: List<AvailableSource>,
val repoUrl: String? = null,
) : Extension() ) : Extension()
@Serializable @Serializable

View file

@ -164,9 +164,7 @@ class ExtensionDetailsController(bundle: Bundle? = null) :
} }
private fun openRepo() { private fun openRepo() {
// TODO val url = getUrl(presenter.extension?.repoUrl)
// val url = getUrl(extension.repoUrl)
val url = getUrl()
openInBrowser(url) openInBrowser(url)
} }

View file

@ -85,7 +85,7 @@ import eu.kanade.tachiyomi.data.updater.AppUpdateResult
import eu.kanade.tachiyomi.data.updater.RELEASE_URL import eu.kanade.tachiyomi.data.updater.RELEASE_URL
import eu.kanade.tachiyomi.databinding.MainActivityBinding import eu.kanade.tachiyomi.databinding.MainActivityBinding
import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi import eu.kanade.tachiyomi.extension.api.ExtensionApi
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.MaterialMenuSheet import eu.kanade.tachiyomi.ui.base.MaterialMenuSheet
import eu.kanade.tachiyomi.ui.base.SmallToolbarInterface import eu.kanade.tachiyomi.ui.base.SmallToolbarInterface
@ -955,7 +955,7 @@ open class MainActivity : BaseActivity<MainActivityBinding>() {
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
try { try {
extensionManager.findAvailableExtensions() extensionManager.findAvailableExtensions()
val pendingUpdates = ExtensionGithubApi().checkForUpdates( val pendingUpdates = ExtensionApi().checkForUpdates(
this@MainActivity, this@MainActivity,
extensionManager.availableExtensionsFlow.value.takeIf { it.isNotEmpty() }, extensionManager.availableExtensionsFlow.value.takeIf { it.isNotEmpty() },
) )

View file

@ -6,7 +6,7 @@ import android.os.Build
import android.provider.Settings import android.provider.Settings
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat import androidx.preference.SwitchPreferenceCompat
import dev.yokai.presentation.source.SourceRepoController import dev.yokai.presentation.extension.repo.ExtensionRepoController
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.notification.Notifications
@ -42,7 +42,7 @@ class SettingsBrowseController : SettingsController() {
titleRes = R.string.extensions titleRes = R.string.extensions
preference { preference {
titleRes = R.string.source_repos titleRes = R.string.source_repos
onClick { router.pushController(SourceRepoController().withFadeTransaction()) } onClick { router.pushController(ExtensionRepoController().withFadeTransaction()) }
// TODO: Enable once it's finished // TODO: Enable once it's finished
summary = "Temporarily disabled, will be enabled once it's fully implemented" summary = "Temporarily disabled, will be enabled once it's fully implemented"
isEnabled = BuildConfig.DEBUG isEnabled = BuildConfig.DEBUG

View file

@ -1,11 +1,13 @@
[versions] [versions]
compose = "1.5.3" compose = "1.5.3"
lifecycle = "2.6.2"
[libraries] [libraries]
animation = { module = "androidx.compose.animation:animation", version.ref = "compose" } animation = { module = "androidx.compose.animation:animation", version.ref = "compose" }
foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" }
material = { module = "androidx.compose.material:material", version.ref = "compose" } material = { module = "androidx.compose.material:material", version.ref = "compose" }
material3 = { module = "androidx.compose.material3:material3", version = "1.1.2" } material3 = { module = "androidx.compose.material3:material3", version = "1.1.2" }
lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
ui = { module = "androidx.compose.ui:ui", version.ref = "compose" } ui = { module = "androidx.compose.ui:ui", version.ref = "compose" }
ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" }
ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" } ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "compose" }