Change extension repo to extension store and add support for newer extension index format (#1744)

PR: mihonapp/mihon#3349

Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
Co-authored-by: Jobobby04 <jobobby04@users.noreply.github.com>

(cherry picked from commit b446d2a6b1844f24b2d4912ba8be8b4648549f41)
(cherry picked from commit a0ae52671fe2ddab3883b99f437d521446a8539d)
This commit is contained in:
AntsyLich 2026-06-03 06:35:44 +06:00 committed by Cuong-Tran
parent 21c1b85796
commit da7ee11bb3
71 changed files with 1463 additions and 1305 deletions

View file

@ -11,6 +11,24 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- `Other` - for technical stuff. - `Other` - for technical stuff.
## [Unreleased] ## [Unreleased]
### Added
- Add support for `tachiyomix` extension index format ([@AntsyLich](https://github.com/AntsyLich)) ([#3349](https://github.com/mihonapp/mihon/pull/3349))
### Changed
- Change all reference of extension repo to extension store ([@AntsyLich](https://github.com/AntsyLich)) ([#3349](https://github.com/mihonapp/mihon/pull/3349))
### Fixed
- Add missing `outlineVariant` color to Nord theme ([@CompileConnected](https://github.com/CompileConnected)) ([#3184](https://github.com/mihonapp/mihon/pull/3184))
## [v0.19.9] - 2026-04-11
### Fixed
- Regression with installing/updating extension ([@AntsyLich](https://github.com/AntsyLich))
## [v0.19.8] - 2026-04-11
### Added
- Add dedicated "Support Us" screen ([@AntsyLich](https://github.com/AntsyLich)) ([#3200](https://github.com/mihonapp/mihon/pull/3200))
- Add a one time popup asking for donation from long time users ([@AntsyLich](https://github.com/AntsyLich)) ([#3203](https://github.com/mihonapp/mihon/pull/3203))
### Improved ### Improved
- Show informative error when trying to add unapproved titles to list on MAL ([@MajorTanya](https://github.com/MajorTanya)) ([#3155](https://github.com/mihonapp/mihon/pull/3155)) - Show informative error when trying to add unapproved titles to list on MAL ([@MajorTanya](https://github.com/MajorTanya)) ([#3155](https://github.com/mihonapp/mihon/pull/3155))

View file

@ -73,8 +73,8 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- Deep link to add repos --> <!-- Legacy deeplink to add extension store -->
<intent-filter android:label="@string/action_add_repo"> <intent-filter android:label="@string/action_addExtensionStore">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
@ -84,6 +84,17 @@
<data android:host="add-repo" /> <data android:host="add-repo" />
</intent-filter> </intent-filter>
<!-- Deeplink to add extension store -->
<intent-filter android:label="@string/action_addExtensionStore">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mihon" />
<data android:host="extension-store" />
</intent-filter>
<!-- Open backup files --> <!-- Open backup files -->
<intent-filter android:label="@string/pref_restore_backup"> <intent-filter android:label="@string/pref_restore_backup">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />

View file

@ -25,16 +25,15 @@ import eu.kanade.domain.track.interactor.AddTracks
import eu.kanade.domain.track.interactor.RefreshTracks import eu.kanade.domain.track.interactor.RefreshTracks
import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack
import eu.kanade.domain.track.interactor.TrackChapter import eu.kanade.domain.track.interactor.TrackChapter
import mihon.data.repository.ExtensionRepoRepositoryImpl import mihon.data.extension.repository.ExtensionStoreRepositoryImpl
import mihon.data.extension.service.ExtensionStoreService
import mihon.domain.chapter.interactor.FilterChaptersForDownload import mihon.domain.chapter.interactor.FilterChaptersForDownload
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo import mihon.domain.extension.interactor.AddExtensionStore
import mihon.domain.extensionrepo.interactor.DeleteExtensionRepo import mihon.domain.extension.interactor.GetExtensionStoreCountAsFlow
import mihon.domain.extensionrepo.interactor.GetExtensionRepo import mihon.domain.extension.interactor.GetExtensionStores
import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount import mihon.domain.extension.interactor.RemoveExtensionStore
import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo import mihon.domain.extension.interactor.UpdateExtensionStores
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo import mihon.domain.extension.repository.ExtensionStoreRepository
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import mihon.domain.extensionrepo.service.ExtensionRepoService
import mihon.domain.migration.usecases.MigrateMangaUseCase import mihon.domain.migration.usecases.MigrateMangaUseCase
import mihon.domain.upcoming.interactor.GetUpcomingManga import mihon.domain.upcoming.interactor.GetUpcomingManga
import tachiyomi.data.category.CategoryRepositoryImpl import tachiyomi.data.category.CategoryRepositoryImpl
@ -199,14 +198,14 @@ class DomainModule : InjektModule {
addFactory { ToggleSourcePin(get()) } addFactory { ToggleSourcePin(get()) }
addFactory { TrustExtension(get(), get()) } addFactory { TrustExtension(get(), get()) }
addSingletonFactory<ExtensionRepoRepository> { ExtensionRepoRepositoryImpl(get()) } addSingletonFactory { ExtensionStoreService(get(), get(), get()) }
addFactory { ExtensionRepoService(get(), get()) } addSingletonFactory<ExtensionStoreRepository> { ExtensionStoreRepositoryImpl(get(), get()) }
addFactory { GetExtensionRepo(get()) } addFactory { AddExtensionStore(get()) }
addFactory { GetExtensionRepoCount(get()) } addFactory { GetExtensionStoreCountAsFlow(get()) }
addFactory { CreateExtensionRepo(get(), get()) } addFactory { GetExtensionStores(get()) }
addFactory { DeleteExtensionRepo(get()) } addFactory { RemoveExtensionStore(get()) }
addFactory { ReplaceExtensionRepo(get()) } addFactory { UpdateExtensionStores(get()) }
addFactory { UpdateExtensionRepo(get(), get()) }
addFactory { ToggleIncognito(get()) } addFactory { ToggleIncognito(get()) }
addFactory { GetIncognitoState(get(), get(), get()) } addFactory { GetIncognitoState(get(), get(), get()) }
} }

View file

@ -28,11 +28,7 @@ class GetExtensionLanguages(
) { enabledLanguage, availableExtensions -> ) { enabledLanguage, availableExtensions ->
availableExtensions availableExtensions
.flatMap { ext -> .flatMap { ext ->
if (ext.sources.isEmpty()) { ext.sources.map { it.lang }
listOf(ext.lang)
} else {
ext.sources.map { it.lang }
}
} }
.distinct() .distinct()
.sortedWith( .sortedWith(

View file

@ -50,9 +50,6 @@ class GetExtensionsByType(
(showNsfwSources || !extension.isNsfw) (showNsfwSources || !extension.isNsfw)
} }
.flatMap { ext -> .flatMap { ext ->
if (ext.sources.isEmpty()) {
return@flatMap if (ext.lang in enabledLanguages) listOf(ext) else emptyList()
}
ext.sources.filter { it.lang in enabledLanguages } ext.sources.filter { it.lang in enabledLanguages }
.map { .map {
ext.copy( ext.copy(

View file

@ -4,21 +4,21 @@ import android.content.pm.PackageInfo
import androidx.core.content.pm.PackageInfoCompat import androidx.core.content.pm.PackageInfoCompat
import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.util.system.isDebugBuildType import eu.kanade.tachiyomi.util.system.isDebugBuildType
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo import mihon.domain.extension.model.KOMIKKU_SIGNATURE
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository import mihon.domain.extension.repository.ExtensionStoreRepository
import tachiyomi.core.common.preference.getAndSet import tachiyomi.core.common.preference.getAndSet
class TrustExtension( class TrustExtension(
private val extensionRepoRepository: ExtensionRepoRepository, private val repository: ExtensionStoreRepository,
private val preferences: SourcePreferences, private val preferences: SourcePreferences,
) { ) {
suspend fun isTrusted(pkgInfo: PackageInfo, fingerprints: List<String>): Boolean { suspend fun isTrusted(pkgInfo: PackageInfo, fingerprints: List<String>): Boolean {
// KMK --> // KMK -->
if (isDebugBuildType) return true if (isDebugBuildType) return true
if (fingerprints.contains(CreateExtensionRepo.KOMIKKU_SIGNATURE)) return true if (fingerprints.contains(KOMIKKU_SIGNATURE)) return true
// KMK <-- // KMK <--
val trustedFingerprints = extensionRepoRepository.getAll().map { it.signingKeyFingerprint }.toHashSet() val trustedFingerprints = repository.getAll().map { it.signingKey }.toHashSet()
val key = "${pkgInfo.packageName}:${PackageInfoCompat.getLongVersionCode(pkgInfo)}:${fingerprints.last()}" val key = "${pkgInfo.packageName}:${PackageInfoCompat.getLongVersionCode(pkgInfo)}:${fingerprints.last()}"
return trustedFingerprints.any { fingerprints.contains(it) } || key in preferences.trustedExtensions().get() return trustedFingerprints.any { fingerprints.contains(it) } || key in preferences.trustedExtensions().get()
} }

View file

@ -61,7 +61,7 @@ class SyncPreferences(
tracking = preferenceStore.getBoolean("tracking", true).get(), tracking = preferenceStore.getBoolean("tracking", true).get(),
history = preferenceStore.getBoolean("history", true).get(), history = preferenceStore.getBoolean("history", true).get(),
appSettings = preferenceStore.getBoolean("appSettings", true).get(), appSettings = preferenceStore.getBoolean("appSettings", true).get(),
extensionRepoSettings = preferenceStore.getBoolean("extensionRepoSettings", true).get(), extensionStores = preferenceStore.getBoolean("extensionStores", true).get(),
sourceSettings = preferenceStore.getBoolean("sourceSettings", true).get(), sourceSettings = preferenceStore.getBoolean("sourceSettings", true).get(),
privateSettings = preferenceStore.getBoolean("privateSettings", true).get(), privateSettings = preferenceStore.getBoolean("privateSettings", true).get(),
@ -80,7 +80,7 @@ class SyncPreferences(
preferenceStore.getBoolean("tracking", true).set(syncSettings.tracking) preferenceStore.getBoolean("tracking", true).set(syncSettings.tracking)
preferenceStore.getBoolean("history", true).set(syncSettings.history) preferenceStore.getBoolean("history", true).set(syncSettings.history)
preferenceStore.getBoolean("appSettings", true).set(syncSettings.appSettings) preferenceStore.getBoolean("appSettings", true).set(syncSettings.appSettings)
preferenceStore.getBoolean("extensionRepoSettings", true).set(syncSettings.extensionRepoSettings) preferenceStore.getBoolean("extensionStores", true).set(syncSettings.extensionStores)
preferenceStore.getBoolean("sourceSettings", true).set(syncSettings.sourceSettings) preferenceStore.getBoolean("sourceSettings", true).set(syncSettings.sourceSettings)
preferenceStore.getBoolean("privateSettings", true).set(syncSettings.privateSettings) preferenceStore.getBoolean("privateSettings", true).set(syncSettings.privateSettings)

View file

@ -7,7 +7,7 @@ data class SyncSettings(
val tracking: Boolean = true, val tracking: Boolean = true,
val history: Boolean = true, val history: Boolean = true,
val appSettings: Boolean = true, val appSettings: Boolean = true,
val extensionRepoSettings: Boolean = true, val extensionStores: Boolean = true,
val sourceSettings: Boolean = true, val sourceSettings: Boolean = true,
val privateSettings: Boolean = false, val privateSettings: Boolean = false,

View file

@ -86,12 +86,12 @@ fun ExtensionDetailsScreen(
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val url = remember(state.extension) { val url = remember(state.extension) {
val regex = """https://raw.githubusercontent.com/(.+?)/(.+?)/.+""".toRegex() val regex = """https://raw.githubusercontent.com/(.+?)/(.+?)/.+""".toRegex()
regex.find(state.extension?.repoUrl.orEmpty()) regex.find(state.extension?.store?.indexUrl.orEmpty())
?.let { ?.let {
val (user, repo) = it.destructured val (user, repo) = it.destructured
"https://github.com/$user/$repo" "https://github.com/$user/$repo"
} }
?: state.extension?.repoUrl ?: state.extension?.store?.indexUrl
} }
Scaffold( Scaffold(
@ -270,14 +270,17 @@ private fun DetailsHeader(
if (extension is Extension.Installed) { if (extension is Extension.Installed) {
append("\n\n") append("\n\n")
append( appendLine(
""" """
Update available: ${extension.hasUpdate} Update available: ${extension.hasUpdate}
Obsolete: ${extension.isObsolete} Obsolete: ${extension.isObsolete}
Shared: ${extension.isShared} Shared: ${extension.isShared}
Repository: ${extension.repoUrl}
""".trimIndent(), """.trimIndent(),
) )
val store = extension.store
if (store != null) {
append("Repository: ${store.indexUrl}")
}
} }
} }
context.copyToClipboard("Extension Debug information", extDebugInfo) context.copyToClipboard("Extension Debug information", extDebugInfo)

View file

@ -48,7 +48,7 @@ import eu.kanade.presentation.browse.components.BaseBrowseItem
import eu.kanade.presentation.browse.components.ExtensionIcon import eu.kanade.presentation.browse.components.ExtensionIcon
import eu.kanade.presentation.components.WarningBanner import eu.kanade.presentation.components.WarningBanner
import eu.kanade.presentation.manga.components.DotSeparatorNoSpaceText import eu.kanade.presentation.manga.components.DotSeparatorNoSpaceText
import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
import eu.kanade.presentation.util.animateItemFastScroll import eu.kanade.presentation.util.animateItemFastScroll
import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
@ -58,6 +58,8 @@ import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsScreenModel
import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission import eu.kanade.tachiyomi.util.system.launchRequestPackageInstallsPermission
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import mihon.domain.extension.model.ExtensionStore
import mihon.domain.extension.model.KOMIKKU_SIGNATURE
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.i18n.kmk.KMR import tachiyomi.i18n.kmk.KMR
import tachiyomi.i18n.sy.SYMR import tachiyomi.i18n.sy.SYMR
@ -110,9 +112,9 @@ fun ExtensionScreen(
modifier = Modifier.padding(contentPadding), modifier = Modifier.padding(contentPadding),
actions = persistentListOf( actions = persistentListOf(
EmptyScreenAction( EmptyScreenAction(
stringRes = MR.strings.label_extension_repos, stringRes = MR.strings.extensionStores,
icon = Icons.Outlined.Settings, icon = Icons.Outlined.Settings,
onClick = { navigator.push(ExtensionReposScreen()) }, onClick = { navigator.push(ExtensionStoresScreen()) },
), ),
), ),
) )
@ -195,9 +197,9 @@ private fun ExtensionContent(
// KMK --> // KMK -->
KMR.strings.extensions_page_more -> { KMR.strings.extensions_page_more -> {
{ {
Button(onClick = { navigator?.push(ExtensionReposScreen()) }) { Button(onClick = { navigator?.push(ExtensionStoresScreen()) }) {
Text( Text(
text = stringResource(MR.strings.action_add_repo), text = stringResource(MR.strings.action_addExtensionStore),
style = LocalTextStyle.current.copy( style = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onPrimary, color = MaterialTheme.colorScheme.onPrimary,
), ),
@ -401,7 +403,7 @@ private fun ExtensionItemContent(
} }
// KMK --> // KMK -->
Text(text = extension.repoName?.let { "@$it" } ?: "(?)") Text(text = extension.storeName?.let { "@$it" } ?: "(?)")
// KMK <-- // KMK <--
val warning = when { val warning = when {
@ -604,11 +606,11 @@ private fun ExtensionItemContentPreview() {
libVersion = 1.0, libVersion = 1.0,
isNsfw = true, isNsfw = true,
signatureHash = "900000", signatureHash = "900000",
repoName = "Repository", storeName = "Repository",
sources = emptyList(), sources = emptyList(),
apkName = "Test", apkUrl = "Test",
iconUrl = "", iconUrl = "",
repoUrl = "", store = ExtensionStore("https://repo", "Komikku", "", KOMIKKU_SIGNATURE, ExtensionStore.Contact("", ""), false),
) )
val extInstalled = Extension.Installed( val extInstalled = Extension.Installed(
name = "Tachiyomi", name = "Tachiyomi",
@ -619,9 +621,9 @@ private fun ExtensionItemContentPreview() {
libVersion = 1.0, libVersion = 1.0,
isNsfw = true, isNsfw = true,
signatureHash = "900000", signatureHash = "900000",
repoName = "Repository", storeName = "Repository",
sources = emptyList(), sources = emptyList(),
repoUrl = "", store = ExtensionStore("https://repo", "Komikku", "", KOMIKKU_SIGNATURE, ExtensionStore.Contact("", ""), false),
pkgFactory = null, pkgFactory = null,
icon = null, icon = null,
hasUpdate = false, hasUpdate = false,
@ -638,12 +640,12 @@ private fun ExtensionItemContentPreview() {
libVersion = 1.0, libVersion = 1.0,
isNsfw = true, isNsfw = true,
signatureHash = "900000", signatureHash = "900000",
repoName = "Repository", storeName = "Repository",
) )
Column { Column {
ExtensionItemContent( ExtensionItemContent(
extension = extAvail.copy( extension = extAvail.copy(
repoName = "Repository extensions minion multiple languages various sources", storeName = "Repository extensions minion multiple languages various sources",
), ),
installStep = InstallStep.Idle, installStep = InstallStep.Idle,
) )

View file

@ -14,11 +14,11 @@ import eu.kanade.core.preference.asState
import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.domain.ui.UiPreferences import eu.kanade.domain.ui.UiPreferences
import eu.kanade.presentation.more.settings.Preference import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
import eu.kanade.tachiyomi.ui.category.sources.SourceCategoryScreen import eu.kanade.tachiyomi.ui.category.sources.SourceCategoryScreen
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount import mihon.domain.extension.interactor.GetExtensionStoreCountAsFlow
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.i18n.kmk.KMR import tachiyomi.i18n.kmk.KMR
@ -43,9 +43,9 @@ object SettingsBrowseScreen : SearchableSettings {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val sourcePreferences = remember { Injekt.get<SourcePreferences>() } val sourcePreferences = remember { Injekt.get<SourcePreferences>() }
val getExtensionRepoCount = remember { Injekt.get<GetExtensionRepoCount>() } val getExtensionStoreCountAsFlow = remember { Injekt.get<GetExtensionStoreCountAsFlow>() }
val reposCount by getExtensionRepoCount.subscribe().collectAsState(0) val reposCount by getExtensionStoreCountAsFlow().collectAsState(0)
// SY --> // SY -->
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -142,10 +142,10 @@ object SettingsBrowseScreen : SearchableSettings {
title = stringResource(MR.strings.pref_hide_in_library_items), title = stringResource(MR.strings.pref_hide_in_library_items),
), ),
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.label_extension_repos), title = stringResource(MR.strings.extensionStores),
subtitle = pluralStringResource(MR.plurals.num_repos, reposCount, reposCount), subtitle = pluralStringResource(MR.plurals.num_repos, reposCount.toInt(), reposCount),
onClick = { onClick = {
navigator.push(ExtensionReposScreen()) navigator.push(ExtensionStoresScreen())
}, },
), ),
), ),

View file

@ -1,205 +0,0 @@
package eu.kanade.presentation.more.settings.screen.browse
import androidx.compose.runtime.Immutable
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.extension.ExtensionManager
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo
import mihon.domain.extensionrepo.interactor.DeleteExtensionRepo
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.i18n.MR
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionReposScreenModel(
private val getExtensionRepo: GetExtensionRepo = Injekt.get(),
private val createExtensionRepo: CreateExtensionRepo = Injekt.get(),
private val deleteExtensionRepo: DeleteExtensionRepo = Injekt.get(),
private val replaceExtensionRepo: ReplaceExtensionRepo = Injekt.get(),
private val updateExtensionRepo: UpdateExtensionRepo = Injekt.get(),
private val extensionManager: ExtensionManager = Injekt.get(),
// KMK -->
private val sourcePreferences: SourcePreferences = Injekt.get(),
// KMK <--
) : StateScreenModel<RepoScreenState>(RepoScreenState.Loading) {
private val _events: Channel<RepoEvent> = Channel(Int.MAX_VALUE)
val events = _events.receiveAsFlow()
init {
screenModelScope.launchIO {
getExtensionRepo.subscribeAll()
.collectLatest { repos ->
mutableState.update {
RepoScreenState.Success(
repos = repos.toImmutableSet(),
// KMK -->
disabledRepos = sourcePreferences.disabledRepos().get(),
// KMK <--
)
}
}
}
// KMK -->
sourcePreferences.disabledRepos().changes()
.onEach { disabledRepos ->
mutableState.update {
when (it) {
is RepoScreenState.Success -> it.copy(disabledRepos = disabledRepos)
else -> it
}
}
}
.launchIn(screenModelScope)
// KMK <--
}
/**
* Creates and adds a new repo to the database.
*
* @param baseUrl The baseUrl of the repo to create.
*/
fun createRepo(baseUrl: String) {
screenModelScope.launchIO {
when (val result = createExtensionRepo.await(baseUrl)) {
CreateExtensionRepo.Result.Success -> extensionManager.findAvailableExtensions()
CreateExtensionRepo.Result.InvalidUrl -> _events.send(RepoEvent.InvalidUrl)
CreateExtensionRepo.Result.RepoAlreadyExists -> _events.send(RepoEvent.RepoAlreadyExists)
is CreateExtensionRepo.Result.DuplicateFingerprint -> {
showDialog(RepoDialog.Conflict(result.oldRepo, result.newRepo))
}
else -> {}
}
}
}
/**
* Inserts a repo to the database, replace a matching repo with the same signing key fingerprint if found.
*
* @param newRepo The repo to insert
*/
fun replaceRepo(newRepo: ExtensionRepo) {
screenModelScope.launchIO {
replaceExtensionRepo.await(newRepo)
}
}
/**
* Refreshes information for each repository.
*/
fun refreshRepos() {
val status = state.value
if (status is RepoScreenState.Success) {
screenModelScope.launchIO {
updateExtensionRepo.awaitAll()
}
}
}
/**
* Deletes the given repo from the database
*/
fun deleteRepo(baseUrl: String) {
// KMK -->
// Remove repo from disabled list
enableRepo(baseUrl)
// KMK <--
screenModelScope.launchIO {
deleteExtensionRepo.await(baseUrl)
extensionManager.findAvailableExtensions()
}
}
// KMK -->
fun enableRepo(baseUrl: String) {
val disabledRepos = sourcePreferences.disabledRepos().get()
if (baseUrl in disabledRepos) {
sourcePreferences.disabledRepos().set(
disabledRepos.filterNot { it == baseUrl }.toSet(),
)
}
}
fun disableRepo(baseUrl: String) {
val disabledRepos = sourcePreferences.disabledRepos().get()
if (baseUrl !in disabledRepos) {
sourcePreferences.disabledRepos().set(
disabledRepos + baseUrl,
)
}
}
fun refreshExtensionList() {
screenModelScope.launchIO {
extensionManager.findAvailableExtensions()
}
}
// KMK <--
fun showDialog(dialog: RepoDialog) {
mutableState.update {
when (it) {
RepoScreenState.Loading -> it
is RepoScreenState.Success -> it.copy(dialog = dialog)
}
}
}
fun dismissDialog() {
mutableState.update {
when (it) {
RepoScreenState.Loading -> it
is RepoScreenState.Success -> it.copy(dialog = null)
}
}
}
}
sealed class RepoEvent {
sealed class LocalizedMessage(val stringRes: StringResource) : RepoEvent()
data object InvalidUrl : LocalizedMessage(MR.strings.invalid_repo_name)
data object RepoAlreadyExists : LocalizedMessage(MR.strings.error_repo_exists)
}
sealed class RepoDialog {
data object Create : RepoDialog()
data class Delete(val repo: String) : RepoDialog()
data class Conflict(val oldRepo: ExtensionRepo, val newRepo: ExtensionRepo) : RepoDialog()
data class Confirm(val url: String) : RepoDialog()
}
sealed class RepoScreenState {
@Immutable
data object Loading : RepoScreenState()
@Immutable
data class Success(
val repos: ImmutableSet<ExtensionRepo>,
val oldRepos: ImmutableSet<String>? = null,
val dialog: RepoDialog? = null,
// KMK -->
val disabledRepos: Set<String> = emptySet(),
// KMK <--
) : RepoScreenState() {
val isEmpty: Boolean
get() = repos.isEmpty()
}
}

View file

@ -8,20 +8,18 @@ import androidx.compose.ui.platform.LocalContext
import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoConfirmDialog import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionStoreConfirmDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoConflictDialog import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionStoreCreateDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoCreateDialog import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionStoreDeleteDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoDeleteDialog import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionStoresScreen
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionReposScreen
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.flow.collectLatest
import tachiyomi.i18n.kmk.KMR import tachiyomi.i18n.kmk.KMR
import tachiyomi.presentation.core.screens.LoadingScreen import tachiyomi.presentation.core.screens.LoadingScreen
class ExtensionReposScreen( class ExtensionStoresScreen(
private val url: String? = null, private val url: String? = null,
) : Screen() { ) : Screen() {
@ -30,80 +28,72 @@ class ExtensionReposScreen(
val context = LocalContext.current val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { ExtensionReposScreenModel() } val screenModel = rememberScreenModel { ExtensionStoresScreenModel() }
val state by screenModel.state.collectAsState() val state by screenModel.state.collectAsState()
LaunchedEffect(url) { LaunchedEffect(url) {
url?.let { screenModel.showDialog(RepoDialog.Confirm(it)) } url?.let { screenModel.addFromDeeplink(url) }
} }
if (state is RepoScreenState.Loading) { if (state is ExtensionStoreScreenState.Loading) {
LoadingScreen() LoadingScreen()
return return
} }
val successState = state as RepoScreenState.Success val successState = state as ExtensionStoreScreenState.Success
ExtensionReposScreen( ExtensionStoresScreen(
state = successState, state = successState,
onClickCreate = { screenModel.showDialog(RepoDialog.Create) }, onClickCreate = { screenModel.showDialog(ExtensionStoreDialog.Create()) },
onOpenWebsite = { context.openInBrowser(it.website) }, onCopy = { context.copyToClipboard(it.indexUrl, it.indexUrl) },
onClickDelete = { screenModel.showDialog(RepoDialog.Delete(it)) }, onOpenWebsite = { it.contact.website.let(context::openInBrowser) },
onOpenDiscord = { it.contact.discord?.let(context::openInBrowser) },
// KMK --> // KMK -->
onClickEnable = { onClickEnable = {
screenModel.enableRepo(it) screenModel.enableStore(it.indexUrl)
screenModel.refreshExtensionList() screenModel.refreshExtensionList()
context.toast(KMR.strings.extensions_page_need_refresh) context.toast(KMR.strings.extensions_page_need_refresh)
}, },
onClickDisable = { onClickDisable = {
screenModel.disableRepo(it) screenModel.disableStore(it.indexUrl)
screenModel.refreshExtensionList() screenModel.refreshExtensionList()
context.toast(KMR.strings.extensions_page_need_refresh) context.toast(KMR.strings.extensions_page_need_refresh)
}, },
// KMK <-- // KMK <--
onClickDelete = { screenModel.showDialog(ExtensionStoreDialog.Delete(it)) },
onClickRefresh = { screenModel.refreshRepos() }, onClickRefresh = { screenModel.refreshRepos() },
navigateUp = navigator::pop, navigateUp = navigator::pop,
) )
when (val dialog = successState.dialog) { when (val dialog = successState.dialog) {
null -> {} null -> {}
is RepoDialog.Create -> { is ExtensionStoreDialog.Create -> {
ExtensionRepoCreateDialog( ExtensionStoreCreateDialog(
onDismissRequest = screenModel::dismissDialog, onDismissRequest = screenModel::dismissDialog,
onCreate = { screenModel.createRepo(it) }, onCreate = { screenModel.createRepo(it) },
repoUrls = successState.repos.map { it.baseUrl }.toImmutableSet(), storeIndexUrls = successState.stores.map { it.indexUrl }.toSet(),
processing = dialog.processing,
errorMessage = dialog.errorMessage,
) )
} }
is RepoDialog.Delete -> { is ExtensionStoreDialog.Delete -> {
ExtensionRepoDeleteDialog( ExtensionStoreDeleteDialog(
onDismissRequest = screenModel::dismissDialog, onDismissRequest = screenModel::dismissDialog,
onDelete = { screenModel.deleteRepo(dialog.repo) }, onDelete = { screenModel.deleteRepo(dialog.store.indexUrl) },
repo = dialog.repo, storeName = dialog.store.name,
storeIndexUrl = dialog.store.indexUrl,
) )
} }
is RepoDialog.Conflict -> { is ExtensionStoreDialog.Confirm -> {
ExtensionRepoConflictDialog( ExtensionStoreConfirmDialog(
onDismissRequest = screenModel::dismissDialog,
onMigrate = { screenModel.replaceRepo(dialog.newRepo) },
oldRepo = dialog.oldRepo,
newRepo = dialog.newRepo,
)
}
is RepoDialog.Confirm -> {
ExtensionRepoConfirmDialog(
onDismissRequest = screenModel::dismissDialog, onDismissRequest = screenModel::dismissDialog,
onCreate = { screenModel.createRepo(dialog.url) }, onCreate = { screenModel.createRepo(dialog.url) },
repo = dialog.url, storeIndexUrl = dialog.url,
storeAlreadyExists = dialog.alreadyExists,
processing = dialog.processing,
errorMessage = dialog.errorMessage,
) )
} }
} }
LaunchedEffect(Unit) {
screenModel.events.collectLatest { event ->
if (event is RepoEvent.LocalizedMessage) {
context.toast(event.stringRes)
}
}
}
} }
} }

View file

@ -0,0 +1,223 @@
package eu.kanade.presentation.more.settings.screen.browse
import androidx.compose.runtime.Immutable
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.extension.ExtensionManager
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import mihon.domain.extension.interactor.AddExtensionStore
import mihon.domain.extension.interactor.GetExtensionStores
import mihon.domain.extension.interactor.RemoveExtensionStore
import mihon.domain.extension.interactor.UpdateExtensionStores
import mihon.domain.extension.model.ExtensionStore
import tachiyomi.core.common.util.lang.launchIO
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionStoresScreenModel(
private val getExtensionStores: GetExtensionStores = Injekt.get(),
private val addExtensionStore: AddExtensionStore = Injekt.get(),
private val removeExtensionStore: RemoveExtensionStore = Injekt.get(),
private val updateExtensionStores: UpdateExtensionStores = Injekt.get(),
private val extensionManager: ExtensionManager = Injekt.get(),
// KMK -->
private val sourcePreferences: SourcePreferences = Injekt.get(),
// KMK <--
) : StateScreenModel<ExtensionStoreScreenState>(ExtensionStoreScreenState.Loading) {
private inline fun updateSuccessState(
func: (ExtensionStoreScreenState.Success) -> ExtensionStoreScreenState.Success,
) {
mutableState.update {
when (it) {
ExtensionStoreScreenState.Loading -> it
is ExtensionStoreScreenState.Success -> func(it)
}
}
}
init {
screenModelScope.launchIO {
getExtensionStores.subscribe()
.collectLatest { stores ->
mutableState.update {
when (it) {
ExtensionStoreScreenState.Loading -> ExtensionStoreScreenState.Success(
stores = stores,
// KMK -->
disabledRepos = sourcePreferences.disabledRepos().get(),
// KMK <--
)
is ExtensionStoreScreenState.Success -> it.copy(stores = stores)
}
}
}
}
// KMK -->
sourcePreferences.disabledRepos().changes()
.onEach { disabledRepos ->
mutableState.update {
when (it) {
is ExtensionStoreScreenState.Success -> it.copy(disabledRepos = disabledRepos)
else -> it
}
}
}
.launchIn(screenModelScope)
// KMK <--
}
/**
* Creates and adds a new repo to the database.
*
* @param indexUrl The baseUrl of the repo to create.
*/
fun createRepo(indexUrl: String) {
// KMK -->
screenModelScope.launchIO {
// KMK <--
updateSuccessState {
it.copy(
dialog = when (it.dialog) {
is ExtensionStoreDialog.Create -> it.dialog.copy(processing = true)
is ExtensionStoreDialog.Confirm -> it.dialog.copy(processing = true)
else -> it.dialog
},
)
}
addExtensionStore(indexUrl)
.onSuccess {
extensionManager.findAvailableExtensions()
dismissDialog()
}
.onFailure { throwable ->
updateSuccessState {
it.copy(
dialog = when (it.dialog) {
is ExtensionStoreDialog.Create -> it.dialog.copy(
processing = false,
errorMessage = throwable.message ?: "unknown error",
)
is ExtensionStoreDialog.Confirm -> it.dialog.copy(
processing = false,
errorMessage = throwable.message ?: "unknown error",
)
else -> it.dialog
},
)
}
}
}
}
/**
* Refreshes information for each repository.
*/
fun refreshRepos() {
val status = state.value
if (status is ExtensionStoreScreenState.Success) {
screenModelScope.launchIO {
updateExtensionStores()
}
}
}
/**
* Deletes the given repo from the database
*/
fun deleteRepo(indexUrl: String) {
// KMK -->
// Remove repo from disabled list
enableStore(indexUrl)
// KMK <--
screenModelScope.launchIO {
removeExtensionStore(indexUrl)
extensionManager.findAvailableExtensions()
}
}
// KMK -->
fun enableStore(indexUrl: String) {
val disabledRepos = sourcePreferences.disabledRepos().get()
if (indexUrl in disabledRepos) {
sourcePreferences.disabledRepos().set(
disabledRepos.filterNot { it == indexUrl }.toSet(),
)
}
}
fun disableStore(indexUrl: String) {
val disabledRepos = sourcePreferences.disabledRepos().get()
if (indexUrl !in disabledRepos) {
sourcePreferences.disabledRepos().set(
disabledRepos + indexUrl,
)
}
}
fun refreshExtensionList() {
screenModelScope.launchIO {
extensionManager.findAvailableExtensions()
}
}
// KMK <--
fun addFromDeeplink(storeIndexUrl: String) {
updateSuccessState { state ->
state.copy(
dialog = ExtensionStoreDialog.Confirm(
url = storeIndexUrl,
alreadyExists = state.stores.any { it.indexUrl == storeIndexUrl },
),
)
}
}
fun showDialog(dialog: ExtensionStoreDialog) {
updateSuccessState { state ->
state.copy(dialog = dialog)
}
}
fun dismissDialog() {
updateSuccessState {
it.copy(dialog = null)
}
}
}
sealed class ExtensionStoreDialog {
data class Create(val processing: Boolean = false, val errorMessage: String? = null) : ExtensionStoreDialog()
data class Delete(val store: ExtensionStore) : ExtensionStoreDialog()
data class Confirm(
val url: String,
val alreadyExists: Boolean = false,
val processing: Boolean = false,
val errorMessage: String? = null,
) : ExtensionStoreDialog()
}
sealed class ExtensionStoreScreenState {
@Immutable
data object Loading : ExtensionStoreScreenState()
@Immutable
data class Success(
val stores: List<ExtensionStore>,
val dialog: ExtensionStoreDialog? = null,
// KMK -->
val disabledRepos: Set<String> = emptySet(),
// KMK <--
) : ExtensionStoreScreenState() {
val isEmpty: Boolean
get() = stores.isEmpty()
}
}

View file

@ -1,188 +0,0 @@
package eu.kanade.presentation.more.settings.screen.browse.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.input.KeyboardType
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.coroutines.delay
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import kotlin.time.Duration.Companion.seconds
@Composable
fun ExtensionRepoCreateDialog(
onDismissRequest: () -> Unit,
onCreate: (String) -> Unit,
repoUrls: ImmutableSet<String>,
) {
var name by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
val nameAlreadyExists = remember(name) { repoUrls.contains(name) }
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(
enabled = name.isNotEmpty() && !nameAlreadyExists,
onClick = {
onCreate(name)
onDismissRequest()
},
) {
Text(text = stringResource(MR.strings.action_add))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
title = {
Text(text = stringResource(MR.strings.action_add_repo))
},
text = {
Column {
Text(text = stringResource(MR.strings.action_add_repo_message, stringResource(MR.strings.app_name)))
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
value = name,
onValueChange = { name = it },
label = {
Text(text = stringResource(MR.strings.label_add_repo_input))
},
supportingText = {
val msgRes = if (name.isNotEmpty() && nameAlreadyExists) {
MR.strings.error_repo_exists
} else {
MR.strings.information_required_plain
}
Text(text = stringResource(msgRes))
},
isError = name.isNotEmpty() && nameAlreadyExists,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
singleLine = true,
)
}
},
)
LaunchedEffect(focusRequester) {
// TODO: https://issuetracker.google.com/issues/204502668
delay(0.1.seconds)
focusRequester.requestFocus()
}
}
@Composable
fun ExtensionRepoDeleteDialog(
onDismissRequest: () -> Unit,
onDelete: () -> Unit,
repo: String,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = {
onDelete()
onDismissRequest()
}) {
Text(text = stringResource(MR.strings.action_ok))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
title = {
Text(text = stringResource(MR.strings.action_delete_repo))
},
text = {
Text(text = stringResource(MR.strings.delete_repo_confirmation, repo))
},
)
}
@Composable
fun ExtensionRepoConflictDialog(
oldRepo: ExtensionRepo,
newRepo: ExtensionRepo,
onDismissRequest: () -> Unit,
onMigrate: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(
onClick = {
onMigrate()
onDismissRequest()
},
) {
Text(text = stringResource(MR.strings.action_replace_repo))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
title = {
Text(text = stringResource(MR.strings.action_replace_repo_title))
},
text = {
Text(text = stringResource(MR.strings.action_replace_repo_message, newRepo.name, oldRepo.name))
},
)
}
@Composable
fun ExtensionRepoConfirmDialog(
onDismissRequest: () -> Unit,
onCreate: () -> Unit,
repo: String,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
title = {
Text(text = stringResource(MR.strings.action_add_repo))
},
text = {
Text(text = stringResource(MR.strings.add_repo_confirmation, repo))
},
confirmButton = {
TextButton(
onClick = {
onCreate()
onDismissRequest()
},
) {
Text(text = stringResource(MR.strings.action_add))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
)
}

View file

@ -11,9 +11,9 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.OpenInNew
import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
@ -26,33 +26,33 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.res.painterResource
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.copyToClipboard import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.ImmutableSet import mihon.domain.extension.model.ExtensionStore
import kotlinx.collections.immutable.persistentSetOf import mihon.domain.extension.model.KOMIKKU_SIGNATURE
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.KOMIKKU_SIGNATURE import mihon.domain.extension.model.REPO_SIGNATURE
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.REPO_SIGNATURE
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.icons.CustomIcons
import tachiyomi.presentation.core.icons.Discord
@Composable @Composable
fun ExtensionReposContent( fun ExtensionStoresContent(
repos: ImmutableSet<ExtensionRepo>, repos: List<ExtensionStore>,
lazyListState: LazyListState, lazyListState: LazyListState,
paddingValues: PaddingValues, paddingValues: PaddingValues,
onOpenWebsite: (ExtensionRepo) -> Unit, onCopy: (ExtensionStore) -> Unit,
onClickDelete: (String) -> Unit, onOpenWebsite: (ExtensionStore) -> Unit,
onOpenDiscord: (ExtensionStore) -> Unit,
onClickDelete: (ExtensionStore) -> Unit,
// KMK --> // KMK -->
onClickEnable: (String) -> Unit, onClickEnable: (ExtensionStore) -> Unit,
onClickDisable: (String) -> Unit, onClickDisable: (ExtensionStore) -> Unit,
disabledRepos: Set<String>, disabledRepos: Set<String>,
// KMK <-- // KMK <--
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -65,15 +65,17 @@ fun ExtensionReposContent(
) { ) {
repos.forEach { repos.forEach {
item { item {
ExtensionRepoListItem( ExtensionStoresListItem(
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
repo = it, store = it,
onOpenWebsite = { onOpenWebsite(it) }, onOpenWebsite = { onOpenWebsite(it) },
onDelete = { onClickDelete(it.baseUrl) }, onOpenDiscord = { onOpenDiscord(it) },
onCopy = { onCopy(it) },
onDelete = { onClickDelete(it) },
// KMK --> // KMK -->
onEnable = { onClickEnable(it.baseUrl) }, onEnable = { onClickEnable(it) },
onDisable = { onClickDisable(it.baseUrl) }, onDisable = { onClickDisable(it) },
isDisabled = it.baseUrl in disabledRepos, isDisabled = it.indexUrl in disabledRepos,
// KMK <-- // KMK <--
) )
} }
@ -82,9 +84,11 @@ fun ExtensionReposContent(
} }
@Composable @Composable
private fun ExtensionRepoListItem( private fun ExtensionStoresListItem(
repo: ExtensionRepo, store: ExtensionStore,
onOpenWebsite: () -> Unit, onOpenWebsite: () -> Unit,
onOpenDiscord: () -> Unit,
onCopy: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
// KMK --> // KMK -->
@ -93,8 +97,6 @@ private fun ExtensionRepoListItem(
onDisable: () -> Unit, onDisable: () -> Unit,
// KMK <-- // KMK <--
) { ) {
val context = LocalContext.current
ElevatedCard( ElevatedCard(
modifier = modifier, modifier = modifier,
) { ) {
@ -103,9 +105,9 @@ private fun ExtensionRepoListItem(
modifier = Modifier modifier = Modifier
.padding(start = MaterialTheme.padding.medium), .padding(start = MaterialTheme.padding.medium),
) { ) {
val resId = repoResId(repo.signingKeyFingerprint) val resId = repoResId(store.signingKey)
Image( Image(
bitmap = ImageBitmap.imageResource(id = resId), painter = painterResource(id = resId),
contentDescription = null, contentDescription = null,
alpha = if (isDisabled) 0.4f else 1f, alpha = if (isDisabled) 0.4f else 1f,
modifier = Modifier modifier = Modifier
@ -126,7 +128,7 @@ private fun ExtensionRepoListItem(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = repo.name, text = store.name,
// KMK: modifier = Modifier.padding(start = MaterialTheme.padding.medium), // KMK: modifier = Modifier.padding(start = MaterialTheme.padding.medium),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
// KMK --> // KMK -->
@ -142,17 +144,21 @@ private fun ExtensionRepoListItem(
) { ) {
IconButton(onClick = onOpenWebsite) { IconButton(onClick = onOpenWebsite) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Outlined.OpenInNew, imageVector = Icons.Outlined.Public,
contentDescription = stringResource(MR.strings.action_open_in_browser), contentDescription = stringResource(MR.strings.action_open_in_browser),
) )
} }
IconButton( if (store.contact.discord != null) {
onClick = { IconButton(onClick = onOpenDiscord) {
val url = "${repo.baseUrl}/index.min.json" Icon(
context.copyToClipboard(url, url) imageVector = CustomIcons.Discord,
}, contentDescription = null,
) { )
}
}
IconButton(onClick = onCopy) {
Icon( Icon(
imageVector = Icons.Outlined.ContentCopy, imageVector = Icons.Outlined.ContentCopy,
contentDescription = stringResource(MR.strings.action_copy_to_clipboard), contentDescription = stringResource(MR.strings.action_copy_to_clipboard),
@ -190,16 +196,18 @@ fun repoResId(signKey: String) = when (signKey) {
@Preview @Preview
@Composable @Composable
fun ExtensionReposContentPreview() { fun ExtensionReposContentPreview() {
val repos = persistentSetOf( val repos = persistentListOf(
ExtensionRepo("https://repo", "Komikku", "", "", KOMIKKU_SIGNATURE), ExtensionStore("https://repo", "Komikku", "", KOMIKKU_SIGNATURE, ExtensionStore.Contact("", ""), false),
ExtensionRepo("https://repo", "Repo", "", "", REPO_SIGNATURE), ExtensionStore("https://repo", "Repo", "", REPO_SIGNATURE, ExtensionStore.Contact("", ""), false),
ExtensionRepo("https://repo", "Other", "", "", "key2"), ExtensionStore("https://repo", "Other", "", "key2", ExtensionStore.Contact("", ""), true),
) )
ExtensionReposContent( ExtensionStoresContent(
repos = repos, repos = repos,
lazyListState = LazyListState(), lazyListState = LazyListState(),
paddingValues = PaddingValues(), paddingValues = PaddingValues(),
onCopy = {},
onOpenWebsite = {}, onOpenWebsite = {},
onOpenDiscord = {},
onClickDelete = {}, onClickDelete = {},
onClickEnable = {}, onClickEnable = {},
onClickDisable = {}, onClickDisable = {},

View file

@ -0,0 +1,192 @@
package eu.kanade.presentation.more.settings.screen.browse.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import kotlin.time.Duration.Companion.seconds
@Composable
fun ExtensionStoreCreateDialog(
onDismissRequest: () -> Unit,
onCreate: (String) -> Unit,
storeIndexUrls: Set<String>,
processing: Boolean,
errorMessage: String?,
) {
val state = rememberTextFieldState()
val storeAlreadyExists by remember(storeIndexUrls) {
derivedStateOf {
val indexUrl = state.text.toString()
storeIndexUrls.contains(indexUrl)
}
}
val focusRequester = remember { FocusRequester() }
AlertDialog(
onDismissRequest = onDismissRequest,
title = {
Text(text = stringResource(MR.strings.extensionStoresScreen_addStore_title))
},
text = {
OutlinedTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
state = state,
label = {
Text(text = stringResource(MR.strings.extensionStoresScreen_addStoreInput_inputLabel))
},
supportingText = {
val msgRes = if (storeAlreadyExists) {
MR.strings.extensionStoresScreen_addStore_alreadyExists
} else {
MR.strings.information_required_plain
}
Text(text = errorMessage ?: stringResource(msgRes))
},
isError = errorMessage != null || storeAlreadyExists,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
lineLimits = TextFieldLineLimits.SingleLine,
)
},
confirmButton = {
TextButton(
onClick = { onCreate(state.text.toString()) },
enabled = !processing && state.text.isNotEmpty() && !storeAlreadyExists,
) {
Text(
text = stringResource(
resource = if (processing) {
MR.strings.extensionStoresScreen_addStore_processing
} else {
MR.strings.action_add
},
),
)
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
)
LaunchedEffect(focusRequester) {
// TODO: https://issuetracker.google.com/issues/204502668
delay(0.1.seconds)
focusRequester.requestFocus()
}
}
@Composable
fun ExtensionStoreDeleteDialog(
onDismissRequest: () -> Unit,
onDelete: () -> Unit,
storeName: String,
storeIndexUrl: String,
) {
AlertDialog(
onDismissRequest = onDismissRequest,
title = {
Text(text = stringResource(MR.strings.extensionStoresScreen_deleteStore_title))
},
text = {
Text(text = stringResource(MR.strings.extensionStoresScreen_deleteStore_body, storeName, storeIndexUrl))
},
confirmButton = {
TextButton(onClick = {
onDelete()
onDismissRequest()
}) {
Text(text = stringResource(MR.strings.action_ok))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
)
}
@Composable
fun ExtensionStoreConfirmDialog(
onDismissRequest: () -> Unit,
onCreate: () -> Unit,
storeIndexUrl: String,
storeAlreadyExists: Boolean,
processing: Boolean,
errorMessage: String?,
) {
val state = rememberTextFieldState(initialText = storeIndexUrl)
AlertDialog(
onDismissRequest = onDismissRequest,
title = {
Text(text = stringResource(MR.strings.extensionStoresScreen_addStore_title))
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = stringResource(MR.strings.extensionStoresScreen_addStoreDeeplink_bodyText))
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
state = state,
readOnly = true,
supportingText = when {
storeAlreadyExists -> {
{
Text(text = stringResource(MR.strings.extensionStoresScreen_addStore_alreadyExists))
}
}
errorMessage != null -> {
{
Text(text = errorMessage)
}
}
else -> null
},
isError = errorMessage != null || storeAlreadyExists,
)
}
},
confirmButton = {
TextButton(onClick = onCreate, enabled = !storeAlreadyExists && !processing) {
Text(
text = stringResource(
resource = if (processing) {
MR.strings.extensionStoresScreen_addStore_processing
} else {
MR.strings.action_add
},
),
)
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(MR.strings.action_cancel))
}
},
)
}

View file

@ -1,5 +1,3 @@
@file:JvmName("ExtensionReposScreenKt")
package eu.kanade.presentation.more.settings.screen.browse.components package eu.kanade.presentation.more.settings.screen.browse.components
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@ -21,13 +19,13 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import eu.kanade.presentation.category.components.CategoryFloatingActionButton import eu.kanade.presentation.category.components.CategoryFloatingActionButton
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.more.settings.screen.browse.RepoScreenState import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoreScreenState
import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.openInBrowser
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentListOf
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.KOMIKKU_SIGNATURE import mihon.domain.extension.model.ExtensionStore
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.REPO_HELP import mihon.domain.extension.model.KOMIKKU_SIGNATURE
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.REPO_SIGNATURE import mihon.domain.extension.model.REPO_HELP
import mihon.domain.extensionrepo.model.ExtensionRepo import mihon.domain.extension.model.REPO_SIGNATURE
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
@ -37,14 +35,16 @@ import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.util.plus import tachiyomi.presentation.core.util.plus
@Composable @Composable
fun ExtensionReposScreen( fun ExtensionStoresScreen(
state: RepoScreenState.Success, state: ExtensionStoreScreenState.Success,
onClickCreate: () -> Unit, onClickCreate: () -> Unit,
onOpenWebsite: (ExtensionRepo) -> Unit, onCopy: (ExtensionStore) -> Unit,
onClickDelete: (String) -> Unit, onOpenWebsite: (ExtensionStore) -> Unit,
onOpenDiscord: (ExtensionStore) -> Unit,
onClickDelete: (ExtensionStore) -> Unit,
// KMK --> // KMK -->
onClickEnable: (String) -> Unit, onClickEnable: (ExtensionStore) -> Unit,
onClickDisable: (String) -> Unit, onClickDisable: (ExtensionStore) -> Unit,
// KMK <-- // KMK <--
onClickRefresh: () -> Unit, onClickRefresh: () -> Unit,
navigateUp: () -> Unit, navigateUp: () -> Unit,
@ -54,7 +54,7 @@ fun ExtensionReposScreen(
topBar = { scrollBehavior -> topBar = { scrollBehavior ->
AppBar( AppBar(
navigateUp = navigateUp, navigateUp = navigateUp,
title = stringResource(MR.strings.label_extension_repos), title = stringResource(MR.strings.extensionStores),
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
actions = { actions = {
IconButton(onClick = onClickRefresh) { IconButton(onClick = onClickRefresh) {
@ -76,7 +76,7 @@ fun ExtensionReposScreen(
if (state.isEmpty) { if (state.isEmpty) {
val context = LocalContext.current val context = LocalContext.current
EmptyScreen( EmptyScreen(
MR.strings.information_empty_repos, MR.strings.extensionStoresScreen_emptyLabel,
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
// KMK --> // KMK -->
help = { help = {
@ -94,12 +94,14 @@ fun ExtensionReposScreen(
return@Scaffold return@Scaffold
} }
ExtensionReposContent( ExtensionStoresContent(
repos = state.repos, repos = state.stores,
lazyListState = lazyListState, lazyListState = lazyListState,
paddingValues = paddingValues + topSmallPaddingValues + paddingValues = paddingValues + topSmallPaddingValues +
PaddingValues(horizontal = MaterialTheme.padding.medium), PaddingValues(horizontal = MaterialTheme.padding.medium),
onCopy = onCopy,
onOpenWebsite = onOpenWebsite, onOpenWebsite = onOpenWebsite,
onOpenDiscord = onOpenDiscord,
onClickDelete = onClickDelete, onClickDelete = onClickDelete,
// KMK --> // KMK -->
onClickEnable = onClickEnable, onClickEnable = onClickEnable,
@ -113,19 +115,21 @@ fun ExtensionReposScreen(
// KMK --> // KMK -->
@Preview @Preview
@Composable @Composable
private fun ExtensionReposScreenPreview() { private fun ExtensionStoresScreenPreview() {
val state = RepoScreenState.Success( val state = ExtensionStoreScreenState.Success(
repos = persistentSetOf( stores = persistentListOf(
ExtensionRepo("https://repo", "Komikku", "", "", KOMIKKU_SIGNATURE), ExtensionStore("https://repo", "Komikku", "", KOMIKKU_SIGNATURE, ExtensionStore.Contact("", ""), false),
ExtensionRepo("https://repo", "Repo", "", "", REPO_SIGNATURE), ExtensionStore("https://repo", "Repo", "", REPO_SIGNATURE, ExtensionStore.Contact("", ""), false),
ExtensionRepo("https://repo", "Other", "", "", "key2"), ExtensionStore("https://repo", "Other", "", "key2", ExtensionStore.Contact("", ""), true),
), ),
disabledRepos = setOf("https://repo"), disabledRepos = setOf("https://repo"),
) )
ExtensionReposScreen( ExtensionStoresScreen(
state = state, state = state,
onClickCreate = { }, onClickCreate = { },
onCopy = { },
onOpenWebsite = { }, onOpenWebsite = { },
onOpenDiscord = { },
onClickDelete = { }, onClickDelete = { },
onClickEnable = { }, onClickEnable = { },
onClickDisable = { }, onClickDisable = { },
@ -136,12 +140,14 @@ private fun ExtensionReposScreenPreview() {
@Preview @Preview
@Composable @Composable
private fun ExtensionReposScreenEmptyPreview() { private fun ExtensionStoresScreenEmptyPreview() {
val state = RepoScreenState.Success(repos = persistentSetOf()) val state = ExtensionStoreScreenState.Success(stores = persistentListOf())
ExtensionReposScreen( ExtensionStoresScreen(
state = state, state = state,
onClickCreate = { }, onClickCreate = { },
onCopy = { },
onOpenWebsite = { }, onOpenWebsite = { },
onOpenDiscord = { },
onClickDelete = { }, onClickDelete = { },
onClickEnable = { }, onClickEnable = { },
onClickDisable = { }, onClickDisable = { },

View file

@ -114,7 +114,7 @@ private class SyncSettingsSelectorModel(
tracking = syncSettings.tracking, tracking = syncSettings.tracking,
history = syncSettings.history, history = syncSettings.history,
appSettings = syncSettings.appSettings, appSettings = syncSettings.appSettings,
extensionRepoSettings = syncSettings.extensionRepoSettings, extensionStores = syncSettings.extensionStores,
sourceSettings = syncSettings.sourceSettings, sourceSettings = syncSettings.sourceSettings,
privateSettings = syncSettings.privateSettings, privateSettings = syncSettings.privateSettings,
@ -134,7 +134,7 @@ private class SyncSettingsSelectorModel(
tracking = backupOptions.tracking, tracking = backupOptions.tracking,
history = backupOptions.history, history = backupOptions.history,
appSettings = backupOptions.appSettings, appSettings = backupOptions.appSettings,
extensionRepoSettings = backupOptions.extensionRepoSettings, extensionStores = backupOptions.extensionStores,
sourceSettings = backupOptions.sourceSettings, sourceSettings = backupOptions.sourceSettings,
privateSettings = backupOptions.privateSettings, privateSettings = backupOptions.privateSettings,

View file

@ -6,7 +6,7 @@ import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.backup.BackupFileValidator import eu.kanade.tachiyomi.data.backup.BackupFileValidator
import eu.kanade.tachiyomi.data.backup.create.creators.CategoriesBackupCreator import eu.kanade.tachiyomi.data.backup.create.creators.CategoriesBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.ExtensionRepoBackupCreator import eu.kanade.tachiyomi.data.backup.create.creators.ExtensionStoresBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.FeedBackupCreator import eu.kanade.tachiyomi.data.backup.create.creators.FeedBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.MangaBackupCreator import eu.kanade.tachiyomi.data.backup.create.creators.MangaBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.PreferenceBackupCreator import eu.kanade.tachiyomi.data.backup.create.creators.PreferenceBackupCreator
@ -14,7 +14,7 @@ import eu.kanade.tachiyomi.data.backup.create.creators.SavedSearchBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.SourcesBackupCreator import eu.kanade.tachiyomi.data.backup.create.creators.SourcesBackupCreator
import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.BackupCategory import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionRepos import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
import eu.kanade.tachiyomi.data.backup.models.BackupFeed import eu.kanade.tachiyomi.data.backup.models.BackupFeed
import eu.kanade.tachiyomi.data.backup.models.BackupManga import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference import eu.kanade.tachiyomi.data.backup.models.BackupPreference
@ -54,7 +54,7 @@ class BackupCreator(
private val categoriesBackupCreator: CategoriesBackupCreator = CategoriesBackupCreator(), private val categoriesBackupCreator: CategoriesBackupCreator = CategoriesBackupCreator(),
private val mangaBackupCreator: MangaBackupCreator = MangaBackupCreator(), private val mangaBackupCreator: MangaBackupCreator = MangaBackupCreator(),
private val preferenceBackupCreator: PreferenceBackupCreator = PreferenceBackupCreator(), private val preferenceBackupCreator: PreferenceBackupCreator = PreferenceBackupCreator(),
private val extensionRepoBackupCreator: ExtensionRepoBackupCreator = ExtensionRepoBackupCreator(), private val extensionStoresBackupCreator: ExtensionStoresBackupCreator = ExtensionStoresBackupCreator(),
private val sourcesBackupCreator: SourcesBackupCreator = SourcesBackupCreator(), private val sourcesBackupCreator: SourcesBackupCreator = SourcesBackupCreator(),
// KMK --> // KMK -->
private val feedBackupCreator: FeedBackupCreator = FeedBackupCreator(), private val feedBackupCreator: FeedBackupCreator = FeedBackupCreator(),
@ -101,7 +101,7 @@ class BackupCreator(
backupCategories = backupCategories(options), backupCategories = backupCategories(options),
backupSources = backupSources(backupManga), backupSources = backupSources(backupManga),
backupPreferences = backupAppPreferences(options), backupPreferences = backupAppPreferences(options),
backupExtensionRepo = backupExtensionRepos(options), backupExtensionStores = backupExtensionStores(options),
backupSourcePreferences = backupSourcePreferences(options), backupSourcePreferences = backupSourcePreferences(options),
// SY --> // SY -->
@ -159,16 +159,16 @@ class BackupCreator(
return sourcesBackupCreator(mangas) return sourcesBackupCreator(mangas)
} }
/* KMK --> */ suspend /* KMK <-- */ fun backupAppPreferences(options: BackupOptions): List<BackupPreference> { internal fun backupAppPreferences(options: BackupOptions): List<BackupPreference> {
if (!options.appSettings) return emptyList() if (!options.appSettings) return emptyList()
return preferenceBackupCreator.createApp(includePrivatePreferences = options.privateSettings) return preferenceBackupCreator.createApp(includePrivatePreferences = options.privateSettings)
} }
suspend fun backupExtensionRepos(options: BackupOptions): List<BackupExtensionRepos> { internal suspend fun backupExtensionStores(options: BackupOptions): List<BackupExtensionStore> {
if (!options.extensionRepoSettings) return emptyList() if (!options.extensionStores) return emptyList()
return extensionRepoBackupCreator() return extensionStoresBackupCreator()
} }
fun backupSourcePreferences(options: BackupOptions): List<BackupSourcePreferences> { fun backupSourcePreferences(options: BackupOptions): List<BackupSourcePreferences> {

View file

@ -14,7 +14,7 @@ data class BackupOptions(
val history: Boolean = true, val history: Boolean = true,
val readEntries: Boolean = true, val readEntries: Boolean = true,
val appSettings: Boolean = true, val appSettings: Boolean = true,
val extensionRepoSettings: Boolean = true, val extensionStores: Boolean = true,
val sourceSettings: Boolean = true, val sourceSettings: Boolean = true,
val privateSettings: Boolean = false, val privateSettings: Boolean = false,
// SY --> // SY -->
@ -31,7 +31,7 @@ data class BackupOptions(
history, history,
readEntries, readEntries,
appSettings, appSettings,
extensionRepoSettings, extensionStores,
sourceSettings, sourceSettings,
privateSettings, privateSettings,
// SY --> // SY -->
@ -41,7 +41,7 @@ data class BackupOptions(
) )
fun canCreate() = fun canCreate() =
libraryEntries || categories || appSettings || extensionRepoSettings || sourceSettings || savedSearchesFeeds libraryEntries || categories || appSettings || extensionStores || sourceSettings || savedSearchesFeeds
companion object { companion object {
val libraryOptions = persistentListOf( val libraryOptions = persistentListOf(
@ -103,9 +103,9 @@ data class BackupOptions(
setter = { options, enabled -> options.copy(appSettings = enabled) }, setter = { options, enabled -> options.copy(appSettings = enabled) },
), ),
Entry( Entry(
label = MR.strings.extensionRepo_settings, label = MR.strings.extensionStores,
getter = BackupOptions::extensionRepoSettings, getter = BackupOptions::extensionStores,
setter = { options, enabled -> options.copy(extensionRepoSettings = enabled) }, setter = { options, enabled -> options.copy(extensionStores = enabled) },
), ),
Entry( Entry(
label = MR.strings.source_settings, label = MR.strings.source_settings,
@ -128,7 +128,7 @@ data class BackupOptions(
history = array[4], history = array[4],
readEntries = array[5], readEntries = array[5],
appSettings = array[6], appSettings = array[6],
extensionRepoSettings = array[7], extensionStores = array[7],
sourceSettings = array[8], sourceSettings = array[8],
privateSettings = array[9], privateSettings = array[9],
// SY --> // SY -->

View file

@ -1,17 +0,0 @@
package eu.kanade.tachiyomi.data.backup.create.creators
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionRepos
import eu.kanade.tachiyomi.data.backup.models.backupExtensionReposMapper
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionRepoBackupCreator(
private val getExtensionRepos: GetExtensionRepo = Injekt.get(),
) {
suspend operator fun invoke(): List<BackupExtensionRepos> {
return getExtensionRepos.getAll()
.map(backupExtensionReposMapper)
}
}

View file

@ -0,0 +1,17 @@
package eu.kanade.tachiyomi.data.backup.create.creators
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
import eu.kanade.tachiyomi.data.backup.models.backupExtensionStoreMapper
import mihon.domain.extension.interactor.GetExtensionStores
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionStoresBackupCreator(
private val getExtensionStores: GetExtensionStores = Injekt.get(),
) {
suspend operator fun invoke(): List<BackupExtensionStore> {
return getExtensionStores.get()
.map(backupExtensionStoreMapper)
}
}

View file

@ -11,7 +11,7 @@ data class Backup(
@ProtoNumber(101) var backupSources: List<BackupSource> = emptyList(), @ProtoNumber(101) var backupSources: List<BackupSource> = emptyList(),
@ProtoNumber(104) var backupPreferences: List<BackupPreference> = emptyList(), @ProtoNumber(104) var backupPreferences: List<BackupPreference> = emptyList(),
@ProtoNumber(105) var backupSourcePreferences: List<BackupSourcePreferences> = emptyList(), @ProtoNumber(105) var backupSourcePreferences: List<BackupSourcePreferences> = emptyList(),
@ProtoNumber(106) var backupExtensionRepo: List<BackupExtensionRepos> = emptyList(), @ProtoNumber(106) var backupExtensionStores: List<BackupExtensionStore> = emptyList(),
// SY specific values // SY specific values
@ProtoNumber(600) var backupSavedSearches: List<BackupSavedSearch> = emptyList(), @ProtoNumber(600) var backupSavedSearches: List<BackupSavedSearch> = emptyList(),
// KMK --> // KMK -->

View file

@ -1,24 +0,0 @@
package eu.kanade.tachiyomi.data.backup.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import mihon.domain.extensionrepo.model.ExtensionRepo
@Serializable
class BackupExtensionRepos(
@ProtoNumber(1) var baseUrl: String,
@ProtoNumber(2) var name: String,
@ProtoNumber(3) var shortName: String?,
@ProtoNumber(4) var website: String,
@ProtoNumber(5) var signingKeyFingerprint: String,
)
val backupExtensionReposMapper = { repo: ExtensionRepo ->
BackupExtensionRepos(
baseUrl = repo.baseUrl,
name = repo.name,
shortName = repo.shortName,
website = repo.website,
signingKeyFingerprint = repo.signingKeyFingerprint,
)
}

View file

@ -0,0 +1,28 @@
package eu.kanade.tachiyomi.data.backup.models
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import mihon.domain.extension.model.ExtensionStore
@Serializable
class BackupExtensionStore(
@ProtoNumber(1) var indexUrl: String,
@ProtoNumber(2) var name: String,
@ProtoNumber(3) var badgeLabel: String?,
@ProtoNumber(5) var signingKey: String,
@ProtoNumber(4) var contactWebsite: String,
@ProtoNumber(6) var contactDiscord: String?,
@ProtoNumber(7) var isLegacy: Boolean?,
)
val backupExtensionStoreMapper = { store: ExtensionStore ->
BackupExtensionStore(
indexUrl = store.indexUrl,
name = store.name,
badgeLabel = store.badgeLabel,
signingKey = store.signingKey,
contactWebsite = store.contact.website,
contactDiscord = store.contact.discord,
isLegacy = store.isLegacy,
)
}

View file

@ -5,14 +5,14 @@ import android.net.Uri
import eu.kanade.tachiyomi.data.backup.BackupDecoder import eu.kanade.tachiyomi.data.backup.BackupDecoder
import eu.kanade.tachiyomi.data.backup.BackupNotifier import eu.kanade.tachiyomi.data.backup.BackupNotifier
import eu.kanade.tachiyomi.data.backup.models.BackupCategory import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionRepos import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
import eu.kanade.tachiyomi.data.backup.models.BackupFeed import eu.kanade.tachiyomi.data.backup.models.BackupFeed
import eu.kanade.tachiyomi.data.backup.models.BackupManga import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference import eu.kanade.tachiyomi.data.backup.models.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSavedSearch import eu.kanade.tachiyomi.data.backup.models.BackupSavedSearch
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
import eu.kanade.tachiyomi.data.backup.restore.restorers.CategoriesRestorer import eu.kanade.tachiyomi.data.backup.restore.restorers.CategoriesRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.ExtensionRepoRestorer import eu.kanade.tachiyomi.data.backup.restore.restorers.ExtensionStoreRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.FeedRestorer import eu.kanade.tachiyomi.data.backup.restore.restorers.FeedRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.MangaRestorer import eu.kanade.tachiyomi.data.backup.restore.restorers.MangaRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.PreferenceRestorer import eu.kanade.tachiyomi.data.backup.restore.restorers.PreferenceRestorer
@ -38,7 +38,7 @@ class BackupRestorer(
private val categoriesRestorer: CategoriesRestorer = CategoriesRestorer(), private val categoriesRestorer: CategoriesRestorer = CategoriesRestorer(),
private val preferenceRestorer: PreferenceRestorer = PreferenceRestorer(context), private val preferenceRestorer: PreferenceRestorer = PreferenceRestorer(context),
private val extensionRepoRestorer: ExtensionRepoRestorer = ExtensionRepoRestorer(), private val extensionStoreRestorer: ExtensionStoreRestorer = ExtensionStoreRestorer(),
private val mangaRestorer: MangaRestorer = MangaRestorer(isSync), private val mangaRestorer: MangaRestorer = MangaRestorer(isSync),
// SY --> // SY -->
private val savedSearchRestorer: SavedSearchRestorer = SavedSearchRestorer(), private val savedSearchRestorer: SavedSearchRestorer = SavedSearchRestorer(),
@ -96,8 +96,8 @@ class BackupRestorer(
if (options.appSettings) { if (options.appSettings) {
restoreAmount += 1 restoreAmount += 1
} }
if (options.extensionRepoSettings) { if (options.extensionStores) {
restoreAmount += backup.backupExtensionRepo.size restoreAmount += backup.backupExtensionStores.size
} }
if (options.sourceSettings) { if (options.sourceSettings) {
restoreAmount += 1 restoreAmount += 1
@ -126,8 +126,8 @@ class BackupRestorer(
if (options.libraryEntries) { if (options.libraryEntries) {
restoreManga(backup.backupManga, if (options.categories) backup.backupCategories else emptyList()) restoreManga(backup.backupManga, if (options.categories) backup.backupCategories else emptyList())
} }
if (options.extensionRepoSettings) { if (options.extensionStores) {
restoreExtensionRepos(backup.backupExtensionRepo) restoreExtensionStores(backup.backupExtensionStores)
} }
// TODO: optionally trigger online library + tracker update // TODO: optionally trigger online library + tracker update
@ -248,23 +248,25 @@ class BackupRestorer(
} }
} }
private fun CoroutineScope.restoreExtensionRepos( private fun CoroutineScope.restoreExtensionStores(
backupExtensionRepo: List<BackupExtensionRepos>, backupExtensionStores: List<BackupExtensionStore>,
) = launch { ) = launch {
backupExtensionRepo backupExtensionStores
.forEach { .forEach {
ensureActive() ensureActive()
try { try {
extensionRepoRestorer(it) extensionStoreRestorer(it)
} catch (e: Exception) { } catch (e: Exception) {
errors.add(Date() to "Error Adding Repo: ${it.name} : ${e.message}") errors.add(Date() to "Error adding extension store: ${it.name} : ${e.message}")
} }
restoreProgress += 1 restoreProgress += 1
// KMK -->
with(notifier) { with(notifier) {
// KMK <--
showRestoreProgress( showRestoreProgress(
context.stringResource(MR.strings.extensionRepo_settings), context.stringResource(MR.strings.extensionStores),
restoreProgress, restoreProgress,
restoreAmount, restoreAmount,
isSync, isSync,

View file

@ -9,7 +9,7 @@ data class RestoreOptions(
val libraryEntries: Boolean = true, val libraryEntries: Boolean = true,
val categories: Boolean = true, val categories: Boolean = true,
val appSettings: Boolean = true, val appSettings: Boolean = true,
val extensionRepoSettings: Boolean = true, val extensionStores: Boolean = true,
val sourceSettings: Boolean = true, val sourceSettings: Boolean = true,
// SY --> // SY -->
val savedSearchesFeeds: Boolean = true, val savedSearchesFeeds: Boolean = true,
@ -20,7 +20,7 @@ data class RestoreOptions(
libraryEntries, libraryEntries,
categories, categories,
appSettings, appSettings,
extensionRepoSettings, extensionStores,
sourceSettings, sourceSettings,
// SY --> // SY -->
savedSearchesFeeds, savedSearchesFeeds,
@ -31,7 +31,7 @@ data class RestoreOptions(
libraryEntries || libraryEntries ||
categories || categories ||
appSettings || appSettings ||
extensionRepoSettings || extensionStores ||
sourceSettings /* SY --> */ || sourceSettings /* SY --> */ ||
savedSearchesFeeds /* SY <-- */ savedSearchesFeeds /* SY <-- */
@ -53,9 +53,9 @@ data class RestoreOptions(
setter = { options, enabled -> options.copy(appSettings = enabled) }, setter = { options, enabled -> options.copy(appSettings = enabled) },
), ),
Entry( Entry(
label = MR.strings.extensionRepo_settings, label = MR.strings.extensionStores,
getter = RestoreOptions::extensionRepoSettings, getter = RestoreOptions::extensionStores,
setter = { options, enabled -> options.copy(extensionRepoSettings = enabled) }, setter = { options, enabled -> options.copy(extensionStores = enabled) },
), ),
Entry( Entry(
label = MR.strings.source_settings, label = MR.strings.source_settings,
@ -77,7 +77,7 @@ data class RestoreOptions(
libraryEntries = array[0], libraryEntries = array[0],
categories = array[1], categories = array[1],
appSettings = array[2], appSettings = array[2],
extensionRepoSettings = array[3], extensionStores = array[3],
sourceSettings = array[4], sourceSettings = array[4],
// SY --> // SY -->
savedSearchesFeeds = array[5], savedSearchesFeeds = array[5],

View file

@ -1,40 +0,0 @@
package eu.kanade.tachiyomi.data.backup.restore.restorers
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionRepos
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
import tachiyomi.data.DatabaseHandler
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionRepoRestorer(
private val handler: DatabaseHandler = Injekt.get(),
private val getExtensionRepos: GetExtensionRepo = Injekt.get(),
) {
suspend operator fun invoke(
backupRepo: BackupExtensionRepos,
) {
val dbRepos = getExtensionRepos.getAll()
val existingReposBySHA = dbRepos.associateBy { it.signingKeyFingerprint }
val existingReposByUrl = dbRepos.associateBy { it.baseUrl }
val urlExists = existingReposByUrl[backupRepo.baseUrl]
val shaExists = existingReposBySHA[backupRepo.signingKeyFingerprint]
if (urlExists != null && urlExists.signingKeyFingerprint != backupRepo.signingKeyFingerprint) {
error("Already Exists with different signing key fingerprint")
} else if (shaExists != null) {
error("${shaExists.name} has the same signing key fingerprint")
} else {
handler.await {
extension_reposQueries.insert(
backupRepo.baseUrl,
backupRepo.name,
backupRepo.shortName,
backupRepo.website,
backupRepo.signingKeyFingerprint,
)
}
}
}
}

View file

@ -0,0 +1,25 @@
package eu.kanade.tachiyomi.data.backup.restore.restorers
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
import tachiyomi.data.Database
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionStoreRestorer(
private val database: Database = Injekt.get(),
) {
suspend operator fun invoke(
backupStore: BackupExtensionStore,
) {
database.extension_storeQueries.upsert(
indexUrl = backupStore.indexUrl,
name = backupStore.name,
badgeLabel = backupStore.badgeLabel ?: backupStore.name,
signingKey = backupStore.signingKey,
contactWebsite = backupStore.contactWebsite,
contactDiscord = backupStore.contactDiscord,
isLegacy = backupStore.isLegacy ?: true,
)
}
}

View file

@ -88,7 +88,7 @@ class SyncManager(
chapters = syncOptions.chapters, chapters = syncOptions.chapters,
tracking = syncOptions.tracking, tracking = syncOptions.tracking,
history = syncOptions.history, history = syncOptions.history,
extensionRepoSettings = syncOptions.extensionRepoSettings, extensionStores = syncOptions.extensionStores,
appSettings = syncOptions.appSettings, appSettings = syncOptions.appSettings,
sourceSettings = syncOptions.sourceSettings, sourceSettings = syncOptions.sourceSettings,
privateSettings = syncOptions.privateSettings, privateSettings = syncOptions.privateSettings,
@ -107,8 +107,8 @@ class SyncManager(
backupCategories = backupCreator.backupCategories(backupOptions), backupCategories = backupCreator.backupCategories(backupOptions),
backupSources = backupCreator.backupSources(backupManga), backupSources = backupCreator.backupSources(backupManga),
backupPreferences = backupCreator.backupAppPreferences(backupOptions), backupPreferences = backupCreator.backupAppPreferences(backupOptions),
backupExtensionRepo = backupCreator.backupExtensionRepos(backupOptions),
backupSourcePreferences = backupCreator.backupSourcePreferences(backupOptions), backupSourcePreferences = backupCreator.backupSourcePreferences(backupOptions),
backupExtensionStores = backupCreator.backupExtensionStores(backupOptions),
// SY --> // SY -->
backupSavedSearches = backupCreator.backupSavedSearches(backupOptions), backupSavedSearches = backupCreator.backupSavedSearches(backupOptions),
@ -192,7 +192,7 @@ class SyncManager(
backupSources = remoteBackup.backupSources, backupSources = remoteBackup.backupSources,
backupPreferences = remoteBackup.backupPreferences, backupPreferences = remoteBackup.backupPreferences,
backupSourcePreferences = remoteBackup.backupSourcePreferences, backupSourcePreferences = remoteBackup.backupSourcePreferences,
backupExtensionRepo = remoteBackup.backupExtensionRepo, backupExtensionStores = remoteBackup.backupExtensionStores,
// SY --> // SY -->
backupSavedSearches = remoteBackup.backupSavedSearches, backupSavedSearches = remoteBackup.backupSavedSearches,
@ -222,7 +222,7 @@ class SyncManager(
appSettings = true, appSettings = true,
sourceSettings = true, sourceSettings = true,
libraryEntries = true, libraryEntries = true,
extensionRepoSettings = true, extensionStores = true,
), ),
) )

View file

@ -273,14 +273,14 @@ class ExtensionManager(
// Ext found: Update installed extensions with new information from repo // Ext found: Update installed extensions with new information from repo
// Also clear isObsolete and set new repo Name if needed // Also clear isObsolete and set new repo Name if needed
val hasUpdate = extension.updateExists(availableExt) val hasUpdate = extension.updateExists(availableExt)
// KMK -->
installedExtensionsMap[pkgName] = extension.copy( installedExtensionsMap[pkgName] = extension.copy(
hasUpdate = hasUpdate, hasUpdate = hasUpdate,
repoUrl = availableExt.repoUrl, store = availableExt.store,
// KMK -->
isObsolete = false, isObsolete = false,
repoName = extension.repoName ?: availableExt.repoName, storeName = extension.storeName ?: availableExt.storeName,
// KMK <--
) )
// KMK <--
changed = true changed = true
} }
} }
@ -298,7 +298,7 @@ class ExtensionManager(
* @param extension The extension to be installed. * @param extension The extension to be installed.
*/ */
fun installExtension(extension: Extension.Available): Flow<InstallStep> { fun installExtension(extension: Extension.Available): Flow<InstallStep> {
return installer.downloadAndInstall(api.getApkUrl(extension), extension) return installer.downloadAndInstall(extension.apkUrl, extension)
} }
/** /**

View file

@ -6,24 +6,13 @@ 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.NetworkHelper
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs
import exh.source.BlacklistedSources import exh.source.BlacklistedSources
import exh.source.ExhPreferences import exh.source.ExhPreferences
import kotlinx.coroutines.async import mihon.domain.extension.interactor.UpdateExtensionStores
import kotlinx.coroutines.awaitAll import mihon.domain.extension.repository.ExtensionStoreRepository
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import logcat.LogPriority
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.core.common.preference.Preference import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.Injekt 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
@ -32,19 +21,16 @@ import kotlin.time.Duration.Companion.days
internal class ExtensionApi { internal class ExtensionApi {
private val networkService: NetworkHelper by injectLazy() private val repository: ExtensionStoreRepository by injectLazy()
private val preferenceStore: PreferenceStore by injectLazy() private val preferenceStore: PreferenceStore by injectLazy()
private val getExtensionRepo: GetExtensionRepo by injectLazy() private val updateExtensionStores: UpdateExtensionStores by injectLazy()
private val updateExtensionRepo: UpdateExtensionRepo by injectLazy()
private val extensionManager: ExtensionManager by injectLazy() private val extensionManager: ExtensionManager by injectLazy()
// SY --> // SY -->
private val sourcePreferences: SourcePreferences by injectLazy() private val sourcePreferences: SourcePreferences by injectLazy()
// SY <-- // SY <--
private val json: Json by injectLazy()
private val lastExtCheck: Preference<Long> by lazy { private val lastExtCheck: Preference<Long> by lazy {
preferenceStore.getLong(Preference.appStateKey("last_ext_check"), 0) preferenceStore.getLong(Preference.appStateKey("last_ext_check"), 0)
} }
@ -54,37 +40,11 @@ internal class ExtensionApi {
val disabledRepos = sourcePreferences.disabledRepos().get() val disabledRepos = sourcePreferences.disabledRepos().get()
// KMK <-- // KMK <--
return withIOContext { return withIOContext {
getExtensionRepo.getAll() repository.fetchExtensions(
// KMK --> // KMK -->
.filterNot { it.baseUrl in disabledRepos } disabledRepos,
// KMK <-- // KMK <--
.map { async { getExtensions(it) } } )
.awaitAll()
.flatten()
}
}
private suspend fun getExtensions(extRepo: ExtensionRepo): List<Extension.Available> {
val repoBaseUrl = extRepo.baseUrl
return try {
val response = networkService.client
.newCall(GET("$repoBaseUrl/index.min.json"))
.awaitSuccess()
with(json) {
response
.parseAs<List<ExtensionJsonObject>>()
.toExtensions(
repoBaseUrl,
// KMK -->
signature = extRepo.signingKeyFingerprint,
repoName = extRepo.shortName ?: extRepo.name,
// KMK <--
)
}
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e) { "Failed to get extensions from $repoBaseUrl" }
emptyList()
} }
} }
@ -99,8 +59,7 @@ internal class ExtensionApi {
return null return null
} }
// Update extension repo details updateExtensionStores()
updateExtensionRepo.awaitAll()
val extensions = if (fromAvailableExtensionList) { val extensions = if (fromAvailableExtensionList) {
extensionManager.availableExtensionsFlow.value extensionManager.availableExtensionsFlow.value
@ -138,47 +97,6 @@ internal class ExtensionApi {
return extensionsWithUpdate return extensionsWithUpdate
} }
private fun List<ExtensionJsonObject>.toExtensions(
repoUrl: String,
// KMK -->
signature: String,
repoName: String,
// KMK <--
): List<Extension.Available> {
return this
.filter {
val libVersion = it.extractLibVersion()
libVersion >= ExtensionLoader.LIB_VERSION_MIN && libVersion <= ExtensionLoader.LIB_VERSION_MAX
}
.map {
Extension.Available(
name = it.name.substringAfter("Tachiyomi: "),
pkgName = it.pkg,
versionName = it.version,
versionCode = it.code,
libVersion = it.extractLibVersion(),
lang = it.lang,
isNsfw = it.nsfw == 1,
sources = it.sources?.map(extensionSourceMapper).orEmpty(),
apkName = it.apk,
iconUrl = "$repoUrl/icon/${it.pkg}.png",
repoUrl = repoUrl,
// KMK -->
signatureHash = signature,
repoName = repoName,
// KMK <--
)
}
}
fun getApkUrl(extension: Extension.Available): String {
return "${extension.repoUrl}/apk/${extension.apkName}"
}
private fun ExtensionJsonObject.extractLibVersion(): Double {
return version.substringBeforeLast('.').toDouble()
}
// SY --> // SY -->
private fun Extension.isBlacklisted( private fun Extension.isBlacklisted(
blacklistEnabled: Boolean = sourcePreferences.enableSourceBlacklist().get(), blacklistEnabled: Boolean = sourcePreferences.enableSourceBlacklist().get(),
@ -194,32 +112,3 @@ internal class ExtensionApi {
} }
// SY <-- // SY <--
} }
@Serializable
private data class ExtensionJsonObject(
val name: String,
val pkg: String,
val apk: String,
val lang: String,
val code: Long,
val version: String,
val nsfw: Int,
val sources: List<ExtensionSourceJsonObject>?,
)
@Serializable
private data class ExtensionSourceJsonObject(
val id: Long,
val lang: String,
val name: String,
val baseUrl: String,
)
private val extensionSourceMapper: (ExtensionSourceJsonObject) -> Extension.Available.Source = {
Extension.Available.Source(
id = it.id,
lang = it.lang,
name = it.name,
baseUrl = it.baseUrl,
)
}

View file

@ -20,8 +20,8 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import logcat.LogPriority import logcat.LogPriority
import mihon.domain.extensionrepo.interactor.GetExtensionRepo import mihon.domain.extension.interactor.GetExtensionStores
import mihon.domain.extensionrepo.model.ExtensionRepo import mihon.domain.extension.model.ExtensionStore
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.File import java.io.File
@ -46,7 +46,7 @@ internal object ExtensionLoader {
private val trustExtension: TrustExtension by injectLazy() private val trustExtension: TrustExtension by injectLazy()
// KMK --> // KMK -->
private val getExtensionRepo: GetExtensionRepo by injectLazy() private val getExtensionStores: GetExtensionStores by injectLazy()
// KMK <-- // KMK <--
private val loadNsfwSource by lazy { private val loadNsfwSource by lazy {
@ -169,7 +169,7 @@ internal object ExtensionLoader {
// Load each extension concurrently and wait for completion // Load each extension concurrently and wait for completion
return runBlocking { return runBlocking {
// KMK --> // KMK -->
val extRepos = getExtensionRepo.getAll() val extStores = getExtensionStores.get()
// KMK <-- // KMK <--
val deferred = extPkgs.map { val deferred = extPkgs.map {
async { async {
@ -177,7 +177,7 @@ internal object ExtensionLoader {
context, context,
it, it,
// KMK --> // KMK -->
extRepos, extStores,
// KMK <-- // KMK <--
) )
} }
@ -228,7 +228,7 @@ internal object ExtensionLoader {
isShared = true, isShared = true,
) )
} }
} catch (error: PackageManager.NameNotFoundException) { } catch (_: PackageManager.NameNotFoundException) {
null null
} }
@ -245,11 +245,11 @@ internal object ExtensionLoader {
context: Context, context: Context,
extensionInfo: ExtensionInfo, extensionInfo: ExtensionInfo,
// KMK --> // KMK -->
extRepos: List<ExtensionRepo>? = null, extStores: List<ExtensionStore>? = null,
// KMK <-- // KMK <--
): LoadResult { ): LoadResult {
// KMK --> // KMK -->
val repos = extRepos ?: getExtensionRepo.getAll() val stores = extStores ?: getExtensionStores.get()
// KMK <-- // KMK <--
val pkgManager = context.packageManager val pkgManager = context.packageManager
val pkgInfo = extensionInfo.packageInfo val pkgInfo = extensionInfo.packageInfo
@ -288,10 +288,10 @@ internal object ExtensionLoader {
libVersion, libVersion,
signatures.last(), signatures.last(),
// KMK --> // KMK -->
repoName = repos.firstOrNull { repo -> storeName = stores.firstOrNull { store ->
signatures.all { it == repo.signingKeyFingerprint } signatures.all { it == store.signingKey }
}?.let { repo -> }?.let { store ->
repo.shortName.takeIf { !it.isNullOrBlank() } ?: repo.name store.badgeLabel.takeIf(String::isNotBlank) ?: store.name
}, },
// KMK <-- // KMK <--
) )
@ -358,10 +358,10 @@ internal object ExtensionLoader {
isShared = extensionInfo.isShared, isShared = extensionInfo.isShared,
// KMK --> // KMK -->
signatureHash = signatures.last(), signatureHash = signatures.last(),
repoName = repos.firstOrNull { repo -> storeName = stores.firstOrNull { store ->
signatures.all { it == repo.signingKeyFingerprint } signatures.all { it == store.signingKey }
}?.let { repo -> }?.let { store ->
repo.shortName.takeIf { !it.isNullOrBlank() } ?: repo.name store.badgeLabel.takeIf(String::isNotBlank) ?: store.name
}, },
// KMK <-- // KMK <--
) )

View file

@ -59,7 +59,7 @@ class ExtensionsScreen(private val searchSource: String? = null) : Screen() {
/* /*
* This will redo the searching for [searchSource] every times the screen is launched, for example when * This will redo the searching for [searchSource] every times the screen is launched, for example when
* back from the [ExtensionFilterScreen] or from the [ExtensionReposScreen]. * back from the [ExtensionFilterScreen] or from the [ExtensionStoresScreen].
* Not really desired but let's accept it. * Not really desired but let's accept it.
*/ */
if (!searchSource.isNullOrBlank()) onChangeSearchQuery(searchSource) if (!searchSource.isNullOrBlank()) onChangeSearchQuery(searchSource)

View file

@ -20,7 +20,7 @@ import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.browse.ExtensionScreen import eu.kanade.presentation.browse.ExtensionScreen
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.TabContent import eu.kanade.presentation.components.TabContent
import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsScreen import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsScreen
import eu.kanade.tachiyomi.ui.webview.WebViewScreen import eu.kanade.tachiyomi.ui.webview.WebViewScreen
@ -62,8 +62,8 @@ fun extensionsTab(
onClick = { navigator.push(ExtensionFilterScreen()) }, onClick = { navigator.push(ExtensionFilterScreen()) },
), ),
AppBar.OverflowAction( AppBar.OverflowAction(
title = stringResource(MR.strings.label_extension_repos), title = stringResource(MR.strings.extensionStores),
onClick = { navigator.push(ExtensionReposScreen()) }, onClick = { navigator.push(ExtensionStoresScreen()) },
), ),
), ),
content = { contentPadding, _ -> content = { contentPadding, _ ->

View file

@ -66,7 +66,7 @@ import eu.kanade.presentation.components.UpdatingBannerBackgroundColor
import eu.kanade.presentation.more.settings.screen.ConfigureExhDialog import eu.kanade.presentation.more.settings.screen.ConfigureExhDialog
import eu.kanade.presentation.more.settings.screen.about.AboutScreen.Companion.getReleaseNotes import eu.kanade.presentation.more.settings.screen.about.AboutScreen.Companion.getReleaseNotes
import eu.kanade.presentation.more.settings.screen.about.WhatsNewDialog import eu.kanade.presentation.more.settings.screen.about.WhatsNewDialog
import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
import eu.kanade.presentation.more.settings.screen.data.RestoreBackupScreen import eu.kanade.presentation.more.settings.screen.data.RestoreBackupScreen
import eu.kanade.presentation.util.AssistContentScreen import eu.kanade.presentation.util.AssistContentScreen
import eu.kanade.presentation.util.DefaultNavigatorScreenTransition import eu.kanade.presentation.util.DefaultNavigatorScreenTransition
@ -693,11 +693,11 @@ class MainActivity : BaseActivity() {
navigator.popUntilRoot() navigator.popUntilRoot()
navigator.push(RestoreBackupScreen(intent.data.toString())) navigator.push(RestoreBackupScreen(intent.data.toString()))
} }
// Deep link to add extension repo // Deep link to add extension store
else if (intent.scheme == "tachiyomi" && intent.data?.host == "add-repo") { else if (intent.isAddExtensionStoreIntent()) {
intent.data?.getQueryParameter("url")?.let { repoUrl -> intent.data?.getQueryParameter("url")?.let { repoUrl ->
navigator.popUntilRoot() navigator.popUntilRoot()
navigator.push(ExtensionReposScreen(repoUrl)) navigator.push(ExtensionStoresScreen(repoUrl))
} }
} }
null null
@ -713,6 +713,11 @@ class MainActivity : BaseActivity() {
return true return true
} }
private fun Intent.isAddExtensionStoreIntent(): Boolean {
return (scheme == "tachiyomi" && data?.host == "add-repo") ||
(scheme == "mihon" && data?.host == "extension-store")
}
companion object { companion object {
const val INTENT_SEARCH = "eu.kanade.tachiyomi.SEARCH" const val INTENT_SEARCH = "eu.kanade.tachiyomi.SEARCH"
const val INTENT_SEARCH_QUERY = "query" const val INTENT_SEARCH_QUERY = "query"

View file

@ -0,0 +1,30 @@
package mihon.core.migration.migrations
import android.app.Application
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import eu.kanade.domain.source.service.SourcePreferences
import mihon.core.migration.Migration
import mihon.core.migration.MigrationContext
import tachiyomi.core.common.util.lang.withIOContext
class DisabledRepoMigration : Migration {
override val version: Float = 80f
override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext {
val context = migrationContext.get<Application>() ?: return@withIOContext false
val sourcePreferences = migrationContext.get<SourcePreferences>() ?: return@withIOContext false
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
prefs.edit {
val disabledRepos = prefs.getStringSet(sourcePreferences.disabledRepos().key(), emptySet()) ?: return@edit
disabledRepos
.map {
it.removeSuffix("/index.min.json").removeSuffix("/index.json") + "/repo.json"
}.toSet()
.let {
putStringSet(sourcePreferences.disabledRepos().key(), it)
}
}
return@withIOContext true
}
}

View file

@ -52,4 +52,7 @@ val migrations: List<Migration>
TrustExtensionRepositoryMigration(), TrustExtensionRepositoryMigration(),
CategoryPreferencesCleanupMigration(), CategoryPreferencesCleanupMigration(),
RemoveDuplicateReaderPreferenceMigration(), RemoveDuplicateReaderPreferenceMigration(),
// KMK -->
DisabledRepoMigration(),
// KMK <--
) )

View file

@ -4,8 +4,7 @@ import eu.kanade.domain.source.service.SourcePreferences
import logcat.LogPriority import logcat.LogPriority
import mihon.core.migration.Migration import mihon.core.migration.Migration
import mihon.core.migration.MigrationContext import mihon.core.migration.MigrationContext
import mihon.domain.extensionrepo.exception.SaveExtensionRepoException import mihon.domain.extension.repository.ExtensionStoreRepository
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
@ -14,18 +13,14 @@ class TrustExtensionRepositoryMigration : Migration {
override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext {
val sourcePreferences = migrationContext.get<SourcePreferences>() ?: return@withIOContext false val sourcePreferences = migrationContext.get<SourcePreferences>() ?: return@withIOContext false
val extensionRepositoryRepository = val repository = migrationContext.get<ExtensionStoreRepository>() ?: return@withIOContext false
migrationContext.get<ExtensionRepoRepository>() ?: return@withIOContext false
for ((index, source) in sourcePreferences.extensionRepos().get().withIndex()) { for ((index, source) in sourcePreferences.extensionRepos().get().withIndex()) {
try { try {
extensionRepositoryRepository.upsertRepo( repository.insertFromPreference(
source, indexUrl = source.removeSuffix("/index.min.json").removeSuffix("/index.json") + "/repo.json",
"Repo #${index + 1}", name = "Repo #${index + 1}",
null,
source,
"NOFINGERPRINT-${index + 1}",
) )
} catch (e: SaveExtensionRepoException) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Error Migrating Extension Repo with baseUrl: $source" } logcat(LogPriority.ERROR, e) { "Error Migrating Extension Repo with baseUrl: $source" }
} }
} }

View file

@ -25,7 +25,7 @@ android {
kotlin { kotlin {
compilerOptions { compilerOptions {
freeCompilerArgs.add("-opt-in=kotlinx.serialization.ExperimentalSerializationApi") optIn.add("kotlinx.serialization.ExperimentalSerializationApi")
} }
} }
@ -34,5 +34,9 @@ dependencies {
implementation(projects.domain) implementation(projects.domain)
implementation(projects.core.common) implementation(projects.core.common)
implementation(kotlinx.serialization.json)
implementation(kotlinx.serialization.json.okio)
implementation(kotlinx.serialization.protobuf)
api(libs.bundles.sqldelight) api(libs.bundles.sqldelight)
} }

View file

@ -0,0 +1,7 @@
package mihon.data.extension.model
import mihon.domain.extension.model.ExtensionStore
interface BaseNetworkExtensionStore {
fun toExtensionStore(indexUrl: String): ExtensionStore
}

View file

@ -0,0 +1,114 @@
package mihon.data.extension.model
import android.annotation.SuppressLint
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonNames
import kotlinx.serialization.protobuf.ProtoNumber
import mihon.domain.extension.model.ExtensionStore
@SuppressLint("UnsafeOptInUsageError")
@Serializable
data class NetworkExtensionStore(
@ProtoNumber(1) val name: String,
@ProtoNumber(2) val badgeLabel: String,
@ProtoNumber(3) val signingKey: String,
@ProtoNumber(4) val contact: Contact,
@ProtoNumber(5) val extensions: List<Extension>,
) : BaseNetworkExtensionStore {
@Serializable
data class Contact(
@ProtoNumber(1) val website: String,
@ProtoNumber(2) val discord: String?,
)
@Serializable
data class Extension(
@ProtoNumber(1) val name: String,
@ProtoNumber(2) val packageName: String,
@ProtoNumber(3) val resources: Resources,
@ProtoNumber(4) val extensionLib: String,
@ProtoNumber(5) val versionCode: Long,
@ProtoNumber(6) val versionName: String,
@ProtoNumber(7) val sources: List<Source>,
)
@Serializable
data class Resources(
@ProtoNumber(1) val apkUrl: String,
@ProtoNumber(2) val iconUrl: String,
)
@Serializable
data class Source(
@ProtoNumber(1) val id: Long,
@ProtoNumber(2) val name: String,
@ProtoNumber(3) val language: String,
@ProtoNumber(4) val homeUrl: String = "",
@ProtoNumber(5) val mirrorUrls: List<String> = emptyList(),
@ProtoNumber(6) val contentRating: ContentRating = ContentRating.SAFE,
@ProtoNumber(7) val message: String? = null,
)
@Suppress("Unused")
enum class ContentRating {
@ProtoNumber(0)
@JsonNames("CONTENT_RATING_SAFE")
SAFE,
@ProtoNumber(1)
@JsonNames("CONTENT_RATING_SUGGESTIVE")
SUGGESTIVE,
@ProtoNumber(2)
@JsonNames("CONTENT_RATING_EROTICA")
EROTICA,
@ProtoNumber(3)
@JsonNames("CONTENT_RATING_PORNOGRAPHIC")
PORNOGRAPHIC,
}
override fun toExtensionStore(indexUrl: String): ExtensionStore {
return ExtensionStore(
indexUrl = indexUrl,
name = name,
badgeLabel = badgeLabel,
signingKey = signingKey,
contact = ExtensionStore.Contact(
website = contact.website,
discord = contact.discord,
),
isLegacy = false,
)
}
fun toAvailableExtensions(store: ExtensionStore): List<eu.kanade.tachiyomi.extension.model.Extension.Available> {
return extensions.map { extension ->
val lang = extension.sources.map { it.language }.toSet()
eu.kanade.tachiyomi.extension.model.Extension.Available(
name = extension.name,
pkgName = extension.packageName,
apkUrl = extension.resources.apkUrl,
iconUrl = extension.resources.iconUrl,
libVersion = extension.extensionLib.toDouble(),
versionCode = extension.versionCode,
versionName = extension.versionName,
lang = if (lang.size == 1) lang.first() else "all",
isNsfw = extension.sources.maxOfOrNull { it.contentRating } == ContentRating.PORNOGRAPHIC,
// KMK -->
signatureHash = store.signingKey,
storeName = store.name,
// KMK <--
sources = extension.sources.map { source ->
eu.kanade.tachiyomi.extension.model.Extension.Available.Source(
id = source.id,
name = source.name,
lang = source.language,
baseUrl = source.homeUrl,
)
},
store = store,
)
}
}
}

View file

@ -0,0 +1,65 @@
package mihon.data.extension.model
import android.annotation.SuppressLint
import eu.kanade.tachiyomi.extension.model.Extension
import kotlinx.serialization.Serializable
import mihon.domain.extension.model.ExtensionStore
@SuppressLint("UnsafeOptInUsageError")
@Serializable
data class NetworkLegacyExtension(
val name: String,
val pkg: String,
val apk: String,
val lang: String,
val code: Long,
val version: String,
val nsfw: Int,
val sources: List<Source>?,
) {
@Serializable
data class Source(
val id: Long,
val lang: String,
val name: String,
val baseUrl: String,
)
fun toAvailableExtension(store: ExtensionStore, storeBaseUrl: String): Extension.Available {
return Extension.Available(
name = name.substringAfter("Tachiyomi: "),
pkgName = pkg,
apkUrl = "$storeBaseUrl/apk/$apk",
iconUrl = "$storeBaseUrl/icon/$pkg.png",
libVersion = version.substringBeforeLast('.').toDouble(),
versionCode = code,
versionName = version,
lang = lang,
isNsfw = nsfw == 1,
// KMK -->
signatureHash = store.signingKey,
storeName = store.name,
// KMK <--
sources = if (sources.isNullOrEmpty()) {
listOf(
Extension.Available.Source(
id = 0,
name = name,
lang = lang,
baseUrl = "",
),
)
} else {
sources.map { source ->
Extension.Available.Source(
id = source.id,
name = source.name,
lang = source.lang,
baseUrl = source.baseUrl,
)
}
},
store = store,
)
}
}

View file

@ -0,0 +1,33 @@
package mihon.data.extension.model
import android.annotation.SuppressLint
import kotlinx.serialization.Serializable
import mihon.domain.extension.model.ExtensionStore
@SuppressLint("UnsafeOptInUsageError")
@Serializable
data class NetworkLegacyExtensionRepo(
val meta: Meta,
) : BaseNetworkExtensionStore {
@Serializable
data class Meta(
val name: String,
val shortName: String?,
val website: String,
val signingKeyFingerprint: String,
)
override fun toExtensionStore(indexUrl: String): ExtensionStore {
return ExtensionStore(
indexUrl = indexUrl,
name = meta.name,
badgeLabel = meta.shortName ?: meta.name,
signingKey = meta.signingKeyFingerprint,
contact = ExtensionStore.Contact(
website = meta.website,
discord = null,
),
isLegacy = true,
)
}
}

View file

@ -0,0 +1,138 @@
package mihon.data.extension.repository
import eu.kanade.tachiyomi.extension.model.Extension
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.supervisorScope
import logcat.LogPriority
import mihon.data.extension.service.ExtensionStoreService
import mihon.domain.extension.model.ExtensionStore
import mihon.domain.extension.repository.ExtensionStoreRepository
import tachiyomi.core.common.util.system.logcat
import tachiyomi.data.DatabaseHandler
class ExtensionStoreRepositoryImpl(
private val service: ExtensionStoreService,
private val handler: DatabaseHandler,
) : ExtensionStoreRepository {
override suspend fun insert(indexUrl: String): Result<Unit> {
return service.fetch(indexUrl).mapCatching { upsert(it) }
}
override suspend fun insertFromPreference(indexUrl: String, name: String) {
handler.await {
extension_storeQueries.upsert(
indexUrl = indexUrl,
name = name,
badgeLabel = name,
signingKey = "NO_SIGNING_KEY",
contactWebsite = indexUrl,
contactDiscord = null,
isLegacy = false,
)
}
}
override suspend fun refreshAll() {
try {
handler.awaitList {
extension_storeQueries.getAll()
}.forEach { store ->
service.fetch(store.index_url)
.mapCatching { upsert(it) }
.onFailure {
logcat(LogPriority.ERROR, it) {
"Failed to refresh extension store '${store.name} (${store.index_url})'"
}
}
}
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
}
}
private suspend fun upsert(store: ExtensionStore) {
handler.await {
extension_storeQueries.upsert(
indexUrl = store.indexUrl,
name = store.name,
badgeLabel = store.badgeLabel,
signingKey = store.signingKey,
contactWebsite = store.contact.website,
contactDiscord = store.contact.discord,
isLegacy = store.isLegacy,
)
}
}
override suspend fun fetchExtensions(
// KMK -->
disabledRepos: Set<String>,
// KMK <--
): List<Extension.Available> {
return try {
supervisorScope {
handler.awaitList {
extension_storeQueries.getAll(::extensionStoreMapper)
}
// KMK -->
.filterNot { it.indexUrl in disabledRepos }
// KMK <--
.map { store ->
async {
service.getExtensions(store).onFailure {
this@ExtensionStoreRepositoryImpl.logcat(LogPriority.ERROR, it) {
"Failed to fetch extensions for store '${store.name} (${store.indexUrl})'"
}
}
}
}
.awaitAll()
.flatMap { it.getOrDefault(emptyList()) }
}
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
emptyList()
}
}
override suspend fun getAll(): List<ExtensionStore> {
return handler.awaitList { extension_storeQueries.getAll(::extensionStoreMapper) }
}
override fun getAllAsFlow(): Flow<List<ExtensionStore>> {
return handler.subscribeToList { extension_storeQueries.getAll(::extensionStoreMapper) }
}
override fun getCountAsFlow(): Flow<Long> {
return handler.subscribeToOne {
extension_storeQueries
.getCount()
}
}
override suspend fun remove(indexUrl: String) {
handler.await { extension_storeQueries.delete(indexUrl) }
}
private fun extensionStoreMapper(
indexUrl: String,
name: String,
badgeLabel: String,
signingKey: String,
contactWebsite: String,
contactDiscord: String?,
isLegacy: Boolean,
): ExtensionStore = ExtensionStore(
indexUrl = indexUrl,
name = name,
badgeLabel = badgeLabel,
signingKey = signingKey,
contact = ExtensionStore.Contact(
website = contactWebsite,
discord = contactDiscord,
),
isLegacy = isLegacy,
)
}

View file

@ -0,0 +1,106 @@
package mihon.data.extension.service
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.awaitSuccess
import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.okio.decodeFromBufferedSource
import kotlinx.serialization.protobuf.ProtoBuf
import logcat.LogPriority
import mihon.data.extension.model.NetworkExtensionStore
import mihon.data.extension.model.NetworkLegacyExtension
import mihon.data.extension.model.NetworkLegacyExtensionRepo
import mihon.domain.extension.model.ExtensionStore
import tachiyomi.core.common.util.system.logcat
import kotlin.coroutines.cancellation.CancellationException
class ExtensionStoreService(
private val network: NetworkHelper,
private val json: Json,
private val protoBuf: ProtoBuf,
) {
suspend fun fetch(indexUrl: String): Result<ExtensionStore> {
var updatedIndexUrl: String = indexUrl
return try {
val store = network.client.newCall(GET(indexUrl)).awaitSuccess().body.source().use { source ->
try {
protoBuf.decodeFromByteArray<NetworkExtensionStore>(source.peek().readByteArray())
// KMK -->
} catch (e: Exception) {
if (e is CancellationException) throw e
// KMK <--
logcat(LogPriority.ERROR, e) {
"Failed to add extension store '$updatedIndexUrl'"
}
try {
json.decodeFromBufferedSource<NetworkExtensionStore>(source.peek())
// KMK -->
} catch (e: Exception) {
if (e is CancellationException) throw e
// KMK <--
logcat(LogPriority.ERROR, e) {
"Failed to add extension store '$updatedIndexUrl'"
}
try {
json.decodeFromBufferedSource<NetworkLegacyExtensionRepo>(source.peek())
} catch (e: IllegalArgumentException) {
if (!indexUrl.endsWith("/index.min.json")) {
throw e
}
logcat(LogPriority.ERROR, e) {
"Failed to add extension store '$updatedIndexUrl'"
}
updatedIndexUrl = indexUrl.replace("/index.min.json", "/repo.json")
network.client.newCall(GET(updatedIndexUrl)).awaitSuccess().use {
json.decodeFromBufferedSource<NetworkLegacyExtensionRepo>(it.body.source())
}
}
}
}
.toExtensionStore(updatedIndexUrl)
}
Result.success(store)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) {
"Failed to add extension store '$updatedIndexUrl'"
}
Result.failure(e)
}
}
suspend fun getExtensions(store: ExtensionStore): Result<List<Extension.Available>> {
return try {
val extensions = if (!store.isLegacy) {
network.client.newCall(GET(store.indexUrl)).awaitSuccess().use { response ->
val source = response.body.source()
try {
protoBuf.decodeFromByteArray<NetworkExtensionStore>(source.peek().readByteArray())
.toAvailableExtensions(store)
// KMK -->
} catch (e: Exception) {
if (e is CancellationException) throw e
// KMK <--
json.decodeFromBufferedSource<NetworkExtensionStore>(source.peek())
.toAvailableExtensions(store)
}
}
} else {
val storeBaseUrl = store.indexUrl.removeSuffix("/repo.json")
network.client.newCall(GET("$storeBaseUrl/index.min.json")).awaitSuccess().use { response ->
val source = response.body.source()
json.decodeFromBufferedSource<List<NetworkLegacyExtension>>(source)
.map { it.toAvailableExtension(store, storeBaseUrl) }
}
}
Result.success(extensions)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Result.failure(e)
}
}
}

View file

@ -1,93 +0,0 @@
package mihon.data.repository
import android.database.sqlite.SQLiteException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import mihon.domain.extensionrepo.exception.SaveExtensionRepoException
import mihon.domain.extensionrepo.model.ExtensionRepo
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import tachiyomi.data.DatabaseHandler
class ExtensionRepoRepositoryImpl(
private val handler: DatabaseHandler,
) : ExtensionRepoRepository {
override fun subscribeAll(): Flow<List<ExtensionRepo>> {
return handler.subscribeToList { extension_reposQueries.findAll(::mapExtensionRepo) }
}
override suspend fun getAll(): List<ExtensionRepo> {
return handler.awaitList { extension_reposQueries.findAll(::mapExtensionRepo) }
}
override suspend fun getRepo(baseUrl: String): ExtensionRepo? {
return handler.awaitOneOrNull { extension_reposQueries.findOne(baseUrl, ::mapExtensionRepo) }
}
override suspend fun getRepoBySigningKeyFingerprint(fingerprint: String): ExtensionRepo? {
return handler.awaitOneOrNull {
extension_reposQueries.findOneBySigningKeyFingerprint(fingerprint, ::mapExtensionRepo)
}
}
override fun getCount(): Flow<Int> {
return handler.subscribeToOne { extension_reposQueries.count() }.map { it.toInt() }
}
override suspend fun insertRepo(
baseUrl: String,
name: String,
shortName: String?,
website: String,
signingKeyFingerprint: String,
) {
try {
handler.await { extension_reposQueries.insert(baseUrl, name, shortName, website, signingKeyFingerprint) }
} catch (ex: SQLiteException) {
throw SaveExtensionRepoException(ex)
}
}
override suspend fun upsertRepo(
baseUrl: String,
name: String,
shortName: String?,
website: String,
signingKeyFingerprint: String,
) {
try {
handler.await { extension_reposQueries.upsert(baseUrl, name, shortName, website, signingKeyFingerprint) }
} catch (ex: SQLiteException) {
throw SaveExtensionRepoException(ex)
}
}
override suspend fun replaceRepo(newRepo: ExtensionRepo) {
handler.await {
extension_reposQueries.replace(
newRepo.baseUrl,
newRepo.name,
newRepo.shortName,
newRepo.website,
newRepo.signingKeyFingerprint,
)
}
}
override suspend fun deleteRepo(baseUrl: String) {
return handler.await { extension_reposQueries.delete(baseUrl) }
}
private fun mapExtensionRepo(
baseUrl: String,
name: String,
shortName: String?,
website: String,
signingKeyFingerprint: String,
): ExtensionRepo = ExtensionRepo(
baseUrl = baseUrl,
name = name,
shortName = shortName,
website = website,
signingKeyFingerprint = signingKeyFingerprint,
)
}

View file

@ -1,57 +0,0 @@
CREATE TABLE extension_repos (
base_url TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
short_name TEXT,
website TEXT NOT NULL,
signing_key_fingerprint TEXT UNIQUE NOT NULL
);
findOne:
SELECT *
FROM extension_repos
WHERE base_url = :base_url;
findOneBySigningKeyFingerprint:
SELECT *
FROM extension_repos
WHERE signing_key_fingerprint = :fingerprint;
findAll:
SELECT *
FROM extension_repos;
count:
SELECT COUNT(*)
FROM extension_repos;
insert:
INSERT INTO extension_repos(base_url, name, short_name, website, signing_key_fingerprint)
VALUES (:base_url, :name, :short_name, :website, :fingerprint);
upsert:
INSERT INTO extension_repos(base_url, name, short_name, website, signing_key_fingerprint)
VALUES (:base_url, :name, :short_name, :website, :fingerprint)
ON CONFLICT(base_url)
DO UPDATE
SET
name = :name,
short_name = :short_name,
website =: website,
signing_key_fingerprint = :fingerprint
WHERE base_url = base_url;
replace:
INSERT INTO extension_repos(base_url, name, short_name, website, signing_key_fingerprint)
VALUES (:base_url, :name, :short_name, :website, :fingerprint)
ON CONFLICT(signing_key_fingerprint)
DO UPDATE
SET
base_url = :base_url,
name = :name,
short_name = :short_name,
website =: website
WHERE signing_key_fingerprint = signing_key_fingerprint;
delete:
DELETE FROM extension_repos
WHERE base_url = :base_url;

View file

@ -0,0 +1,42 @@
import kotlin.Boolean;
CREATE TABLE extension_store(
index_url TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
badge_label TEXT NOT NULL,
signing_key TEXT NOT NULL,
contact_website TEXT NOT NULL,
contact_discord TEXT,
is_legacy INTEGER AS Boolean NOT NULL
);
get:
SELECT *
FROM extension_store
WHERE index_url = :indexUrl;
getAll:
SELECT *
FROM extension_store;
getCount:
SELECT COUNT(*)
FROM extension_store;
upsert:
INSERT INTO extension_store(index_url, name, badge_label, signing_key, contact_website, contact_discord, is_legacy)
VALUES (:indexUrl, :name, :badgeLabel, :signingKey, :contactWebsite, :contactDiscord, :isLegacy)
ON CONFLICT(index_url)
DO UPDATE
SET
name = :name,
badge_label = :badgeLabel,
signing_key = :signingKey,
contact_website = :contactWebsite,
contact_discord = :contactDiscord,
is_legacy = :isLegacy
WHERE index_url = :indexUrl;
delete:
DELETE FROM extension_store
WHERE index_url = :indexUrl;

View file

@ -0,0 +1,16 @@
import kotlin.Boolean;
CREATE TABLE extension_store(
index_url TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
badge_label TEXT NOT NULL,
signing_key TEXT NOT NULL,
contact_website TEXT NOT NULL,
contact_discord TEXT,
is_legacy INTEGER AS Boolean NOT NULL
);
INSERT INTO extension_store(index_url, name, badge_label, signing_key, contact_website, contact_discord, is_legacy)
SELECT base_url || '/repo.json', name, coalesce(short_name, name), signing_key_fingerprint, website, NULL, 1 FROM extension_repos;
DROP TABLE extension_repos;

View file

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.extension.model
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import mihon.domain.extension.model.ExtensionStore
import tachiyomi.domain.source.model.StubSource import tachiyomi.domain.source.model.StubSource
sealed class Extension { sealed class Extension {
@ -16,7 +17,7 @@ sealed class Extension {
// KMK --> // KMK -->
abstract val signatureHash: String abstract val signatureHash: String
abstract val repoName: String? abstract val storeName: String?
// KMK <-- // KMK <--
data class Installed( data class Installed(
@ -29,8 +30,8 @@ sealed class Extension {
override val isNsfw: Boolean, override val isNsfw: Boolean,
// KMK --> // KMK -->
override val signatureHash: String, override val signatureHash: String,
/** Guessing repo name from built-in signatures preset */ /** Guessing store name from built-in signatures preset */
override val repoName: String? = null, override val storeName: String? = null,
// KMK <-- // KMK <--
val pkgFactory: String?, val pkgFactory: String?,
val sources: List<Source>, val sources: List<Source>,
@ -38,7 +39,7 @@ sealed class Extension {
val hasUpdate: Boolean = false, val hasUpdate: Boolean = false,
val isObsolete: Boolean = false, val isObsolete: Boolean = false,
val isShared: Boolean, val isShared: Boolean,
val repoUrl: String? = null, val store: ExtensionStore? = null,
// SY --> // SY -->
val isRedundant: Boolean = false, val isRedundant: Boolean = false,
// SY <-- // SY <--
@ -54,12 +55,12 @@ sealed class Extension {
override val isNsfw: Boolean, override val isNsfw: Boolean,
// KMK --> // KMK -->
override val signatureHash: String, override val signatureHash: String,
override val repoName: String, override val storeName: String,
// KMK <-- // KMK <--
val sources: List<Source>, val sources: List<Source>,
val apkName: String, val apkUrl: String,
val iconUrl: String, val iconUrl: String,
val repoUrl: String, val store: ExtensionStore,
) : Extension() { ) : Extension() {
data class Source( data class Source(
@ -87,7 +88,7 @@ sealed class Extension {
/* KMK --> */ /* KMK --> */
override /* KMK <-- */ val signatureHash: String, override /* KMK <-- */ val signatureHash: String,
// KMK --> // KMK -->
override val repoName: String? = null, override val storeName: String? = null,
// KMK <-- // KMK <--
override val lang: String? = null, override val lang: String? = null,
override val isNsfw: Boolean = false, override val isNsfw: Boolean = false,

View file

@ -0,0 +1,11 @@
package mihon.domain.extension.interactor
import mihon.domain.extension.repository.ExtensionStoreRepository
class AddExtensionStore(
private val repository: ExtensionStoreRepository,
) {
suspend operator fun invoke(indexUrl: String): Result<Unit> {
return repository.insert(indexUrl)
}
}

View file

@ -0,0 +1,9 @@
package mihon.domain.extension.interactor
import mihon.domain.extension.repository.ExtensionStoreRepository
class GetExtensionStoreCountAsFlow(
private val repository: ExtensionStoreRepository,
) {
operator fun invoke() = repository.getCountAsFlow()
}

View file

@ -0,0 +1,13 @@
package mihon.domain.extension.interactor
import kotlinx.coroutines.flow.Flow
import mihon.domain.extension.model.ExtensionStore
import mihon.domain.extension.repository.ExtensionStoreRepository
class GetExtensionStores(
private val repository: ExtensionStoreRepository,
) {
suspend fun get(): List<ExtensionStore> = repository.getAll()
fun subscribe(): Flow<List<ExtensionStore>> = repository.getAllAsFlow()
}

View file

@ -0,0 +1,11 @@
package mihon.domain.extension.interactor
import mihon.domain.extension.repository.ExtensionStoreRepository
class RemoveExtensionStore(
private val repository: ExtensionStoreRepository,
) {
suspend operator fun invoke(indexUrl: String) {
repository.remove(indexUrl)
}
}

View file

@ -0,0 +1,11 @@
package mihon.domain.extension.interactor
import mihon.domain.extension.repository.ExtensionStoreRepository
class UpdateExtensionStores(
private val repository: ExtensionStoreRepository,
) {
suspend operator fun invoke() {
repository.refreshAll()
}
}

View file

@ -0,0 +1,21 @@
package mihon.domain.extension.model
data class ExtensionStore(
val indexUrl: String,
val name: String,
val badgeLabel: String,
val signingKey: String,
val contact: Contact,
val isLegacy: Boolean,
) {
data class Contact(
val website: String,
val discord: String?,
)
}
const val REPO_HELP = "https://komikku-app.github.io/docs/guides/getting-started#adding-sources"
// cuong-tran's key
const val KOMIKKU_SIGNATURE = "cbec121aa82ebb02aaa73806992e0368a97d47b5451ed6524816d03084c45905"
const val REPO_SIGNATURE = "9add655a78e96c4ec7a53ef89dccb557cb5d767489fac5e785d671a5a75d4da2"

View file

@ -0,0 +1,27 @@
package mihon.domain.extension.repository
import eu.kanade.tachiyomi.extension.model.Extension
import kotlinx.coroutines.flow.Flow
import mihon.domain.extension.model.ExtensionStore
interface ExtensionStoreRepository {
suspend fun insert(indexUrl: String): Result<Unit>
suspend fun insertFromPreference(indexUrl: String, name: String)
suspend fun refreshAll()
suspend fun fetchExtensions(
// KMK -->
disabledRepos: Set<String>,
// KMK <--
): List<Extension.Available>
suspend fun getAll(): List<ExtensionStore>
fun getAllAsFlow(): Flow<List<ExtensionStore>>
fun getCountAsFlow(): Flow<Long>
suspend fun remove(indexUrl: String)
}

View file

@ -1,10 +0,0 @@
package mihon.domain.extensionrepo.exception
import java.io.IOException
/**
* Exception to abstract over SQLiteException and SQLiteConstraintException for multiplatform.
*
* @param throwable the source throwable to include for tracing.
*/
class SaveExtensionRepoException(throwable: Throwable) : IOException("Error Saving Repository to Database", throwable)

View file

@ -1,80 +0,0 @@
package mihon.domain.extensionrepo.interactor
import logcat.LogPriority
import mihon.domain.extensionrepo.exception.SaveExtensionRepoException
import mihon.domain.extensionrepo.model.ExtensionRepo
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import mihon.domain.extensionrepo.service.ExtensionRepoService
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import tachiyomi.core.common.util.system.logcat
class CreateExtensionRepo(
private val repository: ExtensionRepoRepository,
private val service: ExtensionRepoService,
) {
private val repoRegex = """^https://.*/index\.min\.json$""".toRegex()
suspend fun await(indexUrl: String): Result {
val formattedIndexUrl = indexUrl.toHttpUrlOrNull()
?.toString()
?.takeIf { it.matches(repoRegex) }
?: return Result.InvalidUrl
val baseUrl = formattedIndexUrl.removeSuffix("/index.min.json")
return service.fetchRepoDetails(baseUrl)?.let { insert(it) } ?: Result.InvalidUrl
}
private suspend fun insert(repo: ExtensionRepo): Result {
return try {
repository.insertRepo(
repo.baseUrl,
repo.name,
repo.shortName,
repo.website,
repo.signingKeyFingerprint,
)
Result.Success
} catch (e: SaveExtensionRepoException) {
logcat(LogPriority.WARN, e) { "SQL Conflict attempting to add new repository ${repo.baseUrl}" }
return handleInsertionError(repo)
}
}
/**
* Error Handler for insert when there are trying to create new repositories
*
* SaveExtensionRepoException doesn't provide constraint info in exceptions.
* First check if the conflict was on primary key. if so return RepoAlreadyExists
* Then check if the conflict was on fingerprint. if so Return DuplicateFingerprint
* If neither are found, there was some other Error, and return Result.Error
*
* @param repo Extension Repo holder for passing to DB/Error Dialog
*/
private suspend fun handleInsertionError(repo: ExtensionRepo): Result {
val repoExists = repository.getRepo(repo.baseUrl)
if (repoExists != null) {
return Result.RepoAlreadyExists
}
val matchingFingerprintRepo = repository.getRepoBySigningKeyFingerprint(repo.signingKeyFingerprint)
if (matchingFingerprintRepo != null) {
return Result.DuplicateFingerprint(matchingFingerprintRepo, repo)
}
return Result.Error
}
sealed interface Result {
data class DuplicateFingerprint(val oldRepo: ExtensionRepo, val newRepo: ExtensionRepo) : Result
data object InvalidUrl : Result
data object RepoAlreadyExists : Result
data object Success : Result
data object Error : Result
}
companion object {
const val REPO_HELP = "https://komikku-app.github.io/docs/guides/getting-started#adding-sources"
// cuong-tran's key
const val KOMIKKU_SIGNATURE = "cbec121aa82ebb02aaa73806992e0368a97d47b5451ed6524816d03084c45905"
const val REPO_SIGNATURE = "9add655a78e96c4ec7a53ef89dccb557cb5d767489fac5e785d671a5a75d4da2"
}
}

View file

@ -1,11 +0,0 @@
package mihon.domain.extensionrepo.interactor
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
class DeleteExtensionRepo(
private val repository: ExtensionRepoRepository,
) {
suspend fun await(baseUrl: String) {
repository.deleteRepo(baseUrl)
}
}

View file

@ -1,13 +0,0 @@
package mihon.domain.extensionrepo.interactor
import kotlinx.coroutines.flow.Flow
import mihon.domain.extensionrepo.model.ExtensionRepo
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
class GetExtensionRepo(
private val repository: ExtensionRepoRepository,
) {
fun subscribeAll(): Flow<List<ExtensionRepo>> = repository.subscribeAll()
suspend fun getAll(): List<ExtensionRepo> = repository.getAll()
}

View file

@ -1,9 +0,0 @@
package mihon.domain.extensionrepo.interactor
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
class GetExtensionRepoCount(
private val repository: ExtensionRepoRepository,
) {
fun subscribe() = repository.getCount()
}

View file

@ -1,12 +0,0 @@
package mihon.domain.extensionrepo.interactor
import mihon.domain.extensionrepo.model.ExtensionRepo
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
class ReplaceExtensionRepo(
private val repository: ExtensionRepoRepository,
) {
suspend fun await(repo: ExtensionRepo) {
repository.replaceRepo(repo)
}
}

View file

@ -1,30 +0,0 @@
package mihon.domain.extensionrepo.interactor
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import mihon.domain.extensionrepo.model.ExtensionRepo
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import mihon.domain.extensionrepo.service.ExtensionRepoService
class UpdateExtensionRepo(
private val repository: ExtensionRepoRepository,
private val service: ExtensionRepoService,
) {
suspend fun awaitAll() = coroutineScope {
repository.getAll()
.map { async { await(it) } }
.awaitAll()
}
suspend fun await(repo: ExtensionRepo) {
val newRepo = service.fetchRepoDetails(repo.baseUrl) ?: return
if (
repo.signingKeyFingerprint.startsWith("NOFINGERPRINT") ||
repo.signingKeyFingerprint == newRepo.signingKeyFingerprint
) {
repository.upsertRepo(newRepo)
}
}
}

View file

@ -1,9 +0,0 @@
package mihon.domain.extensionrepo.model
data class ExtensionRepo(
val baseUrl: String,
val name: String,
val shortName: String?,
val website: String,
val signingKeyFingerprint: String,
)

View file

@ -1,47 +0,0 @@
package mihon.domain.extensionrepo.repository
import kotlinx.coroutines.flow.Flow
import mihon.domain.extensionrepo.model.ExtensionRepo
interface ExtensionRepoRepository {
fun subscribeAll(): Flow<List<ExtensionRepo>>
suspend fun getAll(): List<ExtensionRepo>
suspend fun getRepo(baseUrl: String): ExtensionRepo?
suspend fun getRepoBySigningKeyFingerprint(fingerprint: String): ExtensionRepo?
fun getCount(): Flow<Int>
suspend fun insertRepo(
baseUrl: String,
name: String,
shortName: String?,
website: String,
signingKeyFingerprint: String,
)
suspend fun upsertRepo(
baseUrl: String,
name: String,
shortName: String?,
website: String,
signingKeyFingerprint: String,
)
suspend fun upsertRepo(repo: ExtensionRepo) {
upsertRepo(
baseUrl = repo.baseUrl,
name = repo.name,
shortName = repo.shortName,
website = repo.website,
signingKeyFingerprint = repo.signingKeyFingerprint,
)
}
suspend fun replaceRepo(newRepo: ExtensionRepo)
suspend fun deleteRepo(baseUrl: String)
}

View file

@ -1,27 +0,0 @@
package mihon.domain.extensionrepo.service
import kotlinx.serialization.Serializable
import mihon.domain.extensionrepo.model.ExtensionRepo
@Serializable
data class ExtensionRepoMetaDto(
val meta: ExtensionRepoDto,
)
@Serializable
data class ExtensionRepoDto(
val name: String,
val shortName: String?,
val website: String,
val signingKeyFingerprint: String,
)
fun ExtensionRepoMetaDto.toExtensionRepo(baseUrl: String): ExtensionRepo {
return ExtensionRepo(
baseUrl = baseUrl,
name = meta.name,
shortName = meta.shortName,
website = meta.website,
signingKeyFingerprint = meta.signingKeyFingerprint,
)
}

View file

@ -1,36 +0,0 @@
package mihon.domain.extensionrepo.service
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs
import kotlinx.serialization.json.Json
import logcat.LogPriority
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat
class ExtensionRepoService(
networkHelper: NetworkHelper,
private val json: Json,
) {
val client = networkHelper.client
suspend fun fetchRepoDetails(
repo: String,
): ExtensionRepo? {
return withIOContext {
try {
with(json) {
client.newCall(GET("$repo/repo.json"))
.awaitSuccess()
.parseAs<ExtensionRepoMetaDto>()
.toExtensionRepo(baseUrl = repo)
}
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Failed to fetch repo details" }
null
}
}
}
}

View file

@ -364,20 +364,19 @@
<string name="ext_revoke_trust">Revoke trusted unknown extensions</string> <string name="ext_revoke_trust">Revoke trusted unknown extensions</string>
<!-- Extension repos --> <!-- Extension repos -->
<string name="label_extension_repos">Extension repos</string>
<string name="information_empty_repos">You have no repos set.</string>
<string name="action_add_repo">Add repo</string>
<string name="label_add_repo_input">Repo URL</string>
<string name="action_add_repo_message">Add additional repos to %s. This should be a URL that ends with \"index.min.json\".</string>
<string name="error_repo_exists">This repo already exists!</string>
<string name="action_delete_repo">Delete repo</string>
<string name="invalid_repo_name">Invalid repo URL</string>
<string name="delete_repo_confirmation">Do you wish to delete the repo \"%s\"?</string>
<string name="add_repo_confirmation">Do you wish to add the repo \"%s\"?</string>
<string name="action_open_repo">Open source repo</string> <string name="action_open_repo">Open source repo</string>
<string name="action_replace_repo">Replace</string>
<string name="action_replace_repo_title">Signing Key Fingerprint Already Exists</string> <!-- Extension store -->
<string name="action_replace_repo_message">Repository %1$s has the same Signing Key Fingerprint as %2$s.\nIf this is expected, %2$s will be replaced, otherwise contact your repo maintainer.</string> <string name="extensionStores">Extension stores</string>
<string name="action.addExtensionStore">Add extension store</string>
<string name="extensionStoresScreen.emptyLabel">You haven\'t added an extension store yet</string>
<string name="extensionStoresScreen.addStore.title">Add extension store</string>
<string name="extensionStoresScreen.addStore.alreadyExists">This extension store already exists</string>
<string name="extensionStoresScreen.addStore.processing">Processing…</string>
<string name="extensionStoresScreen.addStoreInput.inputLabel">Store index URL</string>
<string name="extensionStoresScreen.addStoreDeeplink.bodyText">Do you wish to add the extension store below?</string>
<string name="extensionStoresScreen.deleteStore.title">Remove extension store</string>
<string name="extensionStoresScreen.deleteStore.body">Do you wish to remove the extension store \"%1$s\" (%2$s)?</string>
<!-- Reader section --> <!-- Reader section -->
<string name="pref_fullscreen">Fullscreen</string> <string name="pref_fullscreen">Fullscreen</string>
@ -567,7 +566,6 @@
<string name="backup_choice">What do you want to backup?</string> <string name="backup_choice">What do you want to backup?</string>
<string name="app_settings">App settings</string> <string name="app_settings">App settings</string>
<string name="source_settings">Source settings</string> <string name="source_settings">Source settings</string>
<string name="extensionRepo_settings">Extension repos</string>
<string name="private_settings">Include sensitive settings (e.g., tracker login tokens)</string> <string name="private_settings">Include sensitive settings (e.g., tracker login tokens)</string>
<string name="non_library_settings">All read entries</string> <string name="non_library_settings">All read entries</string>
<string name="creating_backup">Creating backup</string> <string name="creating_backup">Creating backup</string>