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.
## [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
- 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" />
</intent-filter>
<!-- Deep link to add repos -->
<intent-filter android:label="@string/action_add_repo">
<!-- Legacy 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" />
@ -84,6 +84,17 @@
<data android:host="add-repo" />
</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 -->
<intent-filter android:label="@string/pref_restore_backup">
<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.SyncChapterProgressWithTrack
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.extensionrepo.interactor.CreateExtensionRepo
import mihon.domain.extensionrepo.interactor.DeleteExtensionRepo
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount
import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import mihon.domain.extensionrepo.service.ExtensionRepoService
import mihon.domain.extension.interactor.AddExtensionStore
import mihon.domain.extension.interactor.GetExtensionStoreCountAsFlow
import mihon.domain.extension.interactor.GetExtensionStores
import mihon.domain.extension.interactor.RemoveExtensionStore
import mihon.domain.extension.interactor.UpdateExtensionStores
import mihon.domain.extension.repository.ExtensionStoreRepository
import mihon.domain.migration.usecases.MigrateMangaUseCase
import mihon.domain.upcoming.interactor.GetUpcomingManga
import tachiyomi.data.category.CategoryRepositoryImpl
@ -199,14 +198,14 @@ class DomainModule : InjektModule {
addFactory { ToggleSourcePin(get()) }
addFactory { TrustExtension(get(), get()) }
addSingletonFactory<ExtensionRepoRepository> { ExtensionRepoRepositoryImpl(get()) }
addFactory { ExtensionRepoService(get(), get()) }
addFactory { GetExtensionRepo(get()) }
addFactory { GetExtensionRepoCount(get()) }
addFactory { CreateExtensionRepo(get(), get()) }
addFactory { DeleteExtensionRepo(get()) }
addFactory { ReplaceExtensionRepo(get()) }
addFactory { UpdateExtensionRepo(get(), get()) }
addSingletonFactory { ExtensionStoreService(get(), get(), get()) }
addSingletonFactory<ExtensionStoreRepository> { ExtensionStoreRepositoryImpl(get(), get()) }
addFactory { AddExtensionStore(get()) }
addFactory { GetExtensionStoreCountAsFlow(get()) }
addFactory { GetExtensionStores(get()) }
addFactory { RemoveExtensionStore(get()) }
addFactory { UpdateExtensionStores(get()) }
addFactory { ToggleIncognito(get()) }
addFactory { GetIncognitoState(get(), get(), get()) }
}

View file

@ -28,12 +28,8 @@ class GetExtensionLanguages(
) { enabledLanguage, availableExtensions ->
availableExtensions
.flatMap { ext ->
if (ext.sources.isEmpty()) {
listOf(ext.lang)
} else {
ext.sources.map { it.lang }
}
}
.distinct()
.sortedWith(
compareBy<String> { it !in enabledLanguage }.then(LocaleHelper.comparator),

View file

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

View file

@ -4,21 +4,21 @@ import android.content.pm.PackageInfo
import androidx.core.content.pm.PackageInfoCompat
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.tachiyomi.util.system.isDebugBuildType
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import mihon.domain.extension.model.KOMIKKU_SIGNATURE
import mihon.domain.extension.repository.ExtensionStoreRepository
import tachiyomi.core.common.preference.getAndSet
class TrustExtension(
private val extensionRepoRepository: ExtensionRepoRepository,
private val repository: ExtensionStoreRepository,
private val preferences: SourcePreferences,
) {
suspend fun isTrusted(pkgInfo: PackageInfo, fingerprints: List<String>): Boolean {
// KMK -->
if (isDebugBuildType) return true
if (fingerprints.contains(CreateExtensionRepo.KOMIKKU_SIGNATURE)) return true
if (fingerprints.contains(KOMIKKU_SIGNATURE)) return true
// 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()}"
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(),
history = preferenceStore.getBoolean("history", 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(),
privateSettings = preferenceStore.getBoolean("privateSettings", true).get(),
@ -80,7 +80,7 @@ class SyncPreferences(
preferenceStore.getBoolean("tracking", true).set(syncSettings.tracking)
preferenceStore.getBoolean("history", true).set(syncSettings.history)
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("privateSettings", true).set(syncSettings.privateSettings)

View file

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

View file

@ -86,12 +86,12 @@ fun ExtensionDetailsScreen(
val uriHandler = LocalUriHandler.current
val url = remember(state.extension) {
val regex = """https://raw.githubusercontent.com/(.+?)/(.+?)/.+""".toRegex()
regex.find(state.extension?.repoUrl.orEmpty())
regex.find(state.extension?.store?.indexUrl.orEmpty())
?.let {
val (user, repo) = it.destructured
"https://github.com/$user/$repo"
}
?: state.extension?.repoUrl
?: state.extension?.store?.indexUrl
}
Scaffold(
@ -270,14 +270,17 @@ private fun DetailsHeader(
if (extension is Extension.Installed) {
append("\n\n")
append(
appendLine(
"""
Update available: ${extension.hasUpdate}
Obsolete: ${extension.isObsolete}
Shared: ${extension.isShared}
Repository: ${extension.repoUrl}
""".trimIndent(),
)
val store = extension.store
if (store != null) {
append("Repository: ${store.indexUrl}")
}
}
}
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.components.WarningBanner
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.rememberRequestPackageInstallsPermissionState
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.launchRequestPackageInstallsPermission
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.kmk.KMR
import tachiyomi.i18n.sy.SYMR
@ -110,9 +112,9 @@ fun ExtensionScreen(
modifier = Modifier.padding(contentPadding),
actions = persistentListOf(
EmptyScreenAction(
stringRes = MR.strings.label_extension_repos,
stringRes = MR.strings.extensionStores,
icon = Icons.Outlined.Settings,
onClick = { navigator.push(ExtensionReposScreen()) },
onClick = { navigator.push(ExtensionStoresScreen()) },
),
),
)
@ -195,9 +197,9 @@ private fun ExtensionContent(
// KMK -->
KMR.strings.extensions_page_more -> {
{
Button(onClick = { navigator?.push(ExtensionReposScreen()) }) {
Button(onClick = { navigator?.push(ExtensionStoresScreen()) }) {
Text(
text = stringResource(MR.strings.action_add_repo),
text = stringResource(MR.strings.action_addExtensionStore),
style = LocalTextStyle.current.copy(
color = MaterialTheme.colorScheme.onPrimary,
),
@ -401,7 +403,7 @@ private fun ExtensionItemContent(
}
// KMK -->
Text(text = extension.repoName?.let { "@$it" } ?: "(?)")
Text(text = extension.storeName?.let { "@$it" } ?: "(?)")
// KMK <--
val warning = when {
@ -604,11 +606,11 @@ private fun ExtensionItemContentPreview() {
libVersion = 1.0,
isNsfw = true,
signatureHash = "900000",
repoName = "Repository",
storeName = "Repository",
sources = emptyList(),
apkName = "Test",
apkUrl = "Test",
iconUrl = "",
repoUrl = "",
store = ExtensionStore("https://repo", "Komikku", "", KOMIKKU_SIGNATURE, ExtensionStore.Contact("", ""), false),
)
val extInstalled = Extension.Installed(
name = "Tachiyomi",
@ -619,9 +621,9 @@ private fun ExtensionItemContentPreview() {
libVersion = 1.0,
isNsfw = true,
signatureHash = "900000",
repoName = "Repository",
storeName = "Repository",
sources = emptyList(),
repoUrl = "",
store = ExtensionStore("https://repo", "Komikku", "", KOMIKKU_SIGNATURE, ExtensionStore.Contact("", ""), false),
pkgFactory = null,
icon = null,
hasUpdate = false,
@ -638,12 +640,12 @@ private fun ExtensionItemContentPreview() {
libVersion = 1.0,
isNsfw = true,
signatureHash = "900000",
repoName = "Repository",
storeName = "Repository",
)
Column {
ExtensionItemContent(
extension = extAvail.copy(
repoName = "Repository extensions minion multiple languages various sources",
storeName = "Repository extensions minion multiple languages various sources",
),
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.ui.UiPreferences
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.util.system.AuthenticatorUtil.authenticate
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.i18n.MR
import tachiyomi.i18n.kmk.KMR
@ -43,9 +43,9 @@ object SettingsBrowseScreen : SearchableSettings {
val navigator = LocalNavigator.currentOrThrow
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 -->
val scope = rememberCoroutineScope()
@ -142,10 +142,10 @@ object SettingsBrowseScreen : SearchableSettings {
title = stringResource(MR.strings.pref_hide_in_library_items),
),
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.label_extension_repos),
subtitle = pluralStringResource(MR.plurals.num_repos, reposCount, reposCount),
title = stringResource(MR.strings.extensionStores),
subtitle = pluralStringResource(MR.plurals.num_repos, reposCount.toInt(), reposCount),
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.navigator.LocalNavigator
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.ExtensionRepoConflictDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoCreateDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoDeleteDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionReposScreen
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionStoreConfirmDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionStoreCreateDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionStoreDeleteDialog
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionStoresScreen
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.toast
import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.flow.collectLatest
import tachiyomi.i18n.kmk.KMR
import tachiyomi.presentation.core.screens.LoadingScreen
class ExtensionReposScreen(
class ExtensionStoresScreen(
private val url: String? = null,
) : Screen() {
@ -30,80 +28,72 @@ class ExtensionReposScreen(
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { ExtensionReposScreenModel() }
val screenModel = rememberScreenModel { ExtensionStoresScreenModel() }
val state by screenModel.state.collectAsState()
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()
return
}
val successState = state as RepoScreenState.Success
val successState = state as ExtensionStoreScreenState.Success
ExtensionReposScreen(
ExtensionStoresScreen(
state = successState,
onClickCreate = { screenModel.showDialog(RepoDialog.Create) },
onOpenWebsite = { context.openInBrowser(it.website) },
onClickDelete = { screenModel.showDialog(RepoDialog.Delete(it)) },
onClickCreate = { screenModel.showDialog(ExtensionStoreDialog.Create()) },
onCopy = { context.copyToClipboard(it.indexUrl, it.indexUrl) },
onOpenWebsite = { it.contact.website.let(context::openInBrowser) },
onOpenDiscord = { it.contact.discord?.let(context::openInBrowser) },
// KMK -->
onClickEnable = {
screenModel.enableRepo(it)
screenModel.enableStore(it.indexUrl)
screenModel.refreshExtensionList()
context.toast(KMR.strings.extensions_page_need_refresh)
},
onClickDisable = {
screenModel.disableRepo(it)
screenModel.disableStore(it.indexUrl)
screenModel.refreshExtensionList()
context.toast(KMR.strings.extensions_page_need_refresh)
},
// KMK <--
onClickDelete = { screenModel.showDialog(ExtensionStoreDialog.Delete(it)) },
onClickRefresh = { screenModel.refreshRepos() },
navigateUp = navigator::pop,
)
when (val dialog = successState.dialog) {
null -> {}
is RepoDialog.Create -> {
ExtensionRepoCreateDialog(
is ExtensionStoreDialog.Create -> {
ExtensionStoreCreateDialog(
onDismissRequest = screenModel::dismissDialog,
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 -> {
ExtensionRepoDeleteDialog(
is ExtensionStoreDialog.Delete -> {
ExtensionStoreDeleteDialog(
onDismissRequest = screenModel::dismissDialog,
onDelete = { screenModel.deleteRepo(dialog.repo) },
repo = dialog.repo,
onDelete = { screenModel.deleteRepo(dialog.store.indexUrl) },
storeName = dialog.store.name,
storeIndexUrl = dialog.store.indexUrl,
)
}
is RepoDialog.Conflict -> {
ExtensionRepoConflictDialog(
onDismissRequest = screenModel::dismissDialog,
onMigrate = { screenModel.replaceRepo(dialog.newRepo) },
oldRepo = dialog.oldRepo,
newRepo = dialog.newRepo,
)
}
is RepoDialog.Confirm -> {
ExtensionRepoConfirmDialog(
is ExtensionStoreDialog.Confirm -> {
ExtensionStoreConfirmDialog(
onDismissRequest = screenModel::dismissDialog,
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.LazyListState
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.Delete
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.ElevatedCard
@ -26,33 +26,33 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.imageResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.system.copyToClipboard
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.KOMIKKU_SIGNATURE
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.REPO_SIGNATURE
import mihon.domain.extensionrepo.model.ExtensionRepo
import kotlinx.collections.immutable.persistentListOf
import mihon.domain.extension.model.ExtensionStore
import mihon.domain.extension.model.KOMIKKU_SIGNATURE
import mihon.domain.extension.model.REPO_SIGNATURE
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.icons.CustomIcons
import tachiyomi.presentation.core.icons.Discord
@Composable
fun ExtensionReposContent(
repos: ImmutableSet<ExtensionRepo>,
fun ExtensionStoresContent(
repos: List<ExtensionStore>,
lazyListState: LazyListState,
paddingValues: PaddingValues,
onOpenWebsite: (ExtensionRepo) -> Unit,
onClickDelete: (String) -> Unit,
onCopy: (ExtensionStore) -> Unit,
onOpenWebsite: (ExtensionStore) -> Unit,
onOpenDiscord: (ExtensionStore) -> Unit,
onClickDelete: (ExtensionStore) -> Unit,
// KMK -->
onClickEnable: (String) -> Unit,
onClickDisable: (String) -> Unit,
onClickEnable: (ExtensionStore) -> Unit,
onClickDisable: (ExtensionStore) -> Unit,
disabledRepos: Set<String>,
// KMK <--
modifier: Modifier = Modifier,
@ -65,15 +65,17 @@ fun ExtensionReposContent(
) {
repos.forEach {
item {
ExtensionRepoListItem(
ExtensionStoresListItem(
modifier = Modifier.animateItem(),
repo = it,
store = it,
onOpenWebsite = { onOpenWebsite(it) },
onDelete = { onClickDelete(it.baseUrl) },
onOpenDiscord = { onOpenDiscord(it) },
onCopy = { onCopy(it) },
onDelete = { onClickDelete(it) },
// KMK -->
onEnable = { onClickEnable(it.baseUrl) },
onDisable = { onClickDisable(it.baseUrl) },
isDisabled = it.baseUrl in disabledRepos,
onEnable = { onClickEnable(it) },
onDisable = { onClickDisable(it) },
isDisabled = it.indexUrl in disabledRepos,
// KMK <--
)
}
@ -82,9 +84,11 @@ fun ExtensionReposContent(
}
@Composable
private fun ExtensionRepoListItem(
repo: ExtensionRepo,
private fun ExtensionStoresListItem(
store: ExtensionStore,
onOpenWebsite: () -> Unit,
onOpenDiscord: () -> Unit,
onCopy: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
// KMK -->
@ -93,8 +97,6 @@ private fun ExtensionRepoListItem(
onDisable: () -> Unit,
// KMK <--
) {
val context = LocalContext.current
ElevatedCard(
modifier = modifier,
) {
@ -103,9 +105,9 @@ private fun ExtensionRepoListItem(
modifier = Modifier
.padding(start = MaterialTheme.padding.medium),
) {
val resId = repoResId(repo.signingKeyFingerprint)
val resId = repoResId(store.signingKey)
Image(
bitmap = ImageBitmap.imageResource(id = resId),
painter = painterResource(id = resId),
contentDescription = null,
alpha = if (isDisabled) 0.4f else 1f,
modifier = Modifier
@ -126,7 +128,7 @@ private fun ExtensionRepoListItem(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = repo.name,
text = store.name,
// KMK: modifier = Modifier.padding(start = MaterialTheme.padding.medium),
style = MaterialTheme.typography.titleMedium,
// KMK -->
@ -142,17 +144,21 @@ private fun ExtensionRepoListItem(
) {
IconButton(onClick = onOpenWebsite) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.OpenInNew,
imageVector = Icons.Outlined.Public,
contentDescription = stringResource(MR.strings.action_open_in_browser),
)
}
IconButton(
onClick = {
val url = "${repo.baseUrl}/index.min.json"
context.copyToClipboard(url, url)
},
) {
if (store.contact.discord != null) {
IconButton(onClick = onOpenDiscord) {
Icon(
imageVector = CustomIcons.Discord,
contentDescription = null,
)
}
}
IconButton(onClick = onCopy) {
Icon(
imageVector = Icons.Outlined.ContentCopy,
contentDescription = stringResource(MR.strings.action_copy_to_clipboard),
@ -190,16 +196,18 @@ fun repoResId(signKey: String) = when (signKey) {
@Preview
@Composable
fun ExtensionReposContentPreview() {
val repos = persistentSetOf(
ExtensionRepo("https://repo", "Komikku", "", "", KOMIKKU_SIGNATURE),
ExtensionRepo("https://repo", "Repo", "", "", REPO_SIGNATURE),
ExtensionRepo("https://repo", "Other", "", "", "key2"),
val repos = persistentListOf(
ExtensionStore("https://repo", "Komikku", "", KOMIKKU_SIGNATURE, ExtensionStore.Contact("", ""), false),
ExtensionStore("https://repo", "Repo", "", REPO_SIGNATURE, ExtensionStore.Contact("", ""), false),
ExtensionStore("https://repo", "Other", "", "key2", ExtensionStore.Contact("", ""), true),
)
ExtensionReposContent(
ExtensionStoresContent(
repos = repos,
lazyListState = LazyListState(),
paddingValues = PaddingValues(),
onCopy = {},
onOpenWebsite = {},
onOpenDiscord = {},
onClickDelete = {},
onClickEnable = {},
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
import androidx.compose.foundation.layout.PaddingValues
@ -21,13 +19,13 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import eu.kanade.presentation.category.components.CategoryFloatingActionButton
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 kotlinx.collections.immutable.persistentSetOf
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.KOMIKKU_SIGNATURE
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.REPO_HELP
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo.Companion.REPO_SIGNATURE
import mihon.domain.extensionrepo.model.ExtensionRepo
import kotlinx.collections.immutable.persistentListOf
import mihon.domain.extension.model.ExtensionStore
import mihon.domain.extension.model.KOMIKKU_SIGNATURE
import mihon.domain.extension.model.REPO_HELP
import mihon.domain.extension.model.REPO_SIGNATURE
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.padding
@ -37,14 +35,16 @@ import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.util.plus
@Composable
fun ExtensionReposScreen(
state: RepoScreenState.Success,
fun ExtensionStoresScreen(
state: ExtensionStoreScreenState.Success,
onClickCreate: () -> Unit,
onOpenWebsite: (ExtensionRepo) -> Unit,
onClickDelete: (String) -> Unit,
onCopy: (ExtensionStore) -> Unit,
onOpenWebsite: (ExtensionStore) -> Unit,
onOpenDiscord: (ExtensionStore) -> Unit,
onClickDelete: (ExtensionStore) -> Unit,
// KMK -->
onClickEnable: (String) -> Unit,
onClickDisable: (String) -> Unit,
onClickEnable: (ExtensionStore) -> Unit,
onClickDisable: (ExtensionStore) -> Unit,
// KMK <--
onClickRefresh: () -> Unit,
navigateUp: () -> Unit,
@ -54,7 +54,7 @@ fun ExtensionReposScreen(
topBar = { scrollBehavior ->
AppBar(
navigateUp = navigateUp,
title = stringResource(MR.strings.label_extension_repos),
title = stringResource(MR.strings.extensionStores),
scrollBehavior = scrollBehavior,
actions = {
IconButton(onClick = onClickRefresh) {
@ -76,7 +76,7 @@ fun ExtensionReposScreen(
if (state.isEmpty) {
val context = LocalContext.current
EmptyScreen(
MR.strings.information_empty_repos,
MR.strings.extensionStoresScreen_emptyLabel,
modifier = Modifier.padding(paddingValues),
// KMK -->
help = {
@ -94,12 +94,14 @@ fun ExtensionReposScreen(
return@Scaffold
}
ExtensionReposContent(
repos = state.repos,
ExtensionStoresContent(
repos = state.stores,
lazyListState = lazyListState,
paddingValues = paddingValues + topSmallPaddingValues +
PaddingValues(horizontal = MaterialTheme.padding.medium),
onCopy = onCopy,
onOpenWebsite = onOpenWebsite,
onOpenDiscord = onOpenDiscord,
onClickDelete = onClickDelete,
// KMK -->
onClickEnable = onClickEnable,
@ -113,19 +115,21 @@ fun ExtensionReposScreen(
// KMK -->
@Preview
@Composable
private fun ExtensionReposScreenPreview() {
val state = RepoScreenState.Success(
repos = persistentSetOf(
ExtensionRepo("https://repo", "Komikku", "", "", KOMIKKU_SIGNATURE),
ExtensionRepo("https://repo", "Repo", "", "", REPO_SIGNATURE),
ExtensionRepo("https://repo", "Other", "", "", "key2"),
private fun ExtensionStoresScreenPreview() {
val state = ExtensionStoreScreenState.Success(
stores = persistentListOf(
ExtensionStore("https://repo", "Komikku", "", KOMIKKU_SIGNATURE, ExtensionStore.Contact("", ""), false),
ExtensionStore("https://repo", "Repo", "", REPO_SIGNATURE, ExtensionStore.Contact("", ""), false),
ExtensionStore("https://repo", "Other", "", "key2", ExtensionStore.Contact("", ""), true),
),
disabledRepos = setOf("https://repo"),
)
ExtensionReposScreen(
ExtensionStoresScreen(
state = state,
onClickCreate = { },
onCopy = { },
onOpenWebsite = { },
onOpenDiscord = { },
onClickDelete = { },
onClickEnable = { },
onClickDisable = { },
@ -136,12 +140,14 @@ private fun ExtensionReposScreenPreview() {
@Preview
@Composable
private fun ExtensionReposScreenEmptyPreview() {
val state = RepoScreenState.Success(repos = persistentSetOf())
ExtensionReposScreen(
private fun ExtensionStoresScreenEmptyPreview() {
val state = ExtensionStoreScreenState.Success(stores = persistentListOf())
ExtensionStoresScreen(
state = state,
onClickCreate = { },
onCopy = { },
onOpenWebsite = { },
onOpenDiscord = { },
onClickDelete = { },
onClickEnable = { },
onClickDisable = { },

View file

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

View file

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

View file

@ -14,7 +14,7 @@ data class BackupOptions(
val history: Boolean = true,
val readEntries: Boolean = true,
val appSettings: Boolean = true,
val extensionRepoSettings: Boolean = true,
val extensionStores: Boolean = true,
val sourceSettings: Boolean = true,
val privateSettings: Boolean = false,
// SY -->
@ -31,7 +31,7 @@ data class BackupOptions(
history,
readEntries,
appSettings,
extensionRepoSettings,
extensionStores,
sourceSettings,
privateSettings,
// SY -->
@ -41,7 +41,7 @@ data class BackupOptions(
)
fun canCreate() =
libraryEntries || categories || appSettings || extensionRepoSettings || sourceSettings || savedSearchesFeeds
libraryEntries || categories || appSettings || extensionStores || sourceSettings || savedSearchesFeeds
companion object {
val libraryOptions = persistentListOf(
@ -103,9 +103,9 @@ data class BackupOptions(
setter = { options, enabled -> options.copy(appSettings = enabled) },
),
Entry(
label = MR.strings.extensionRepo_settings,
getter = BackupOptions::extensionRepoSettings,
setter = { options, enabled -> options.copy(extensionRepoSettings = enabled) },
label = MR.strings.extensionStores,
getter = BackupOptions::extensionStores,
setter = { options, enabled -> options.copy(extensionStores = enabled) },
),
Entry(
label = MR.strings.source_settings,
@ -128,7 +128,7 @@ data class BackupOptions(
history = array[4],
readEntries = array[5],
appSettings = array[6],
extensionRepoSettings = array[7],
extensionStores = array[7],
sourceSettings = array[8],
privateSettings = array[9],
// 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(104) var backupPreferences: List<BackupPreference> = 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
@ProtoNumber(600) var backupSavedSearches: List<BackupSavedSearch> = emptyList(),
// 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.BackupNotifier
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.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSavedSearch
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.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.MangaRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.PreferenceRestorer
@ -38,7 +38,7 @@ class BackupRestorer(
private val categoriesRestorer: CategoriesRestorer = CategoriesRestorer(),
private val preferenceRestorer: PreferenceRestorer = PreferenceRestorer(context),
private val extensionRepoRestorer: ExtensionRepoRestorer = ExtensionRepoRestorer(),
private val extensionStoreRestorer: ExtensionStoreRestorer = ExtensionStoreRestorer(),
private val mangaRestorer: MangaRestorer = MangaRestorer(isSync),
// SY -->
private val savedSearchRestorer: SavedSearchRestorer = SavedSearchRestorer(),
@ -96,8 +96,8 @@ class BackupRestorer(
if (options.appSettings) {
restoreAmount += 1
}
if (options.extensionRepoSettings) {
restoreAmount += backup.backupExtensionRepo.size
if (options.extensionStores) {
restoreAmount += backup.backupExtensionStores.size
}
if (options.sourceSettings) {
restoreAmount += 1
@ -126,8 +126,8 @@ class BackupRestorer(
if (options.libraryEntries) {
restoreManga(backup.backupManga, if (options.categories) backup.backupCategories else emptyList())
}
if (options.extensionRepoSettings) {
restoreExtensionRepos(backup.backupExtensionRepo)
if (options.extensionStores) {
restoreExtensionStores(backup.backupExtensionStores)
}
// TODO: optionally trigger online library + tracker update
@ -248,23 +248,25 @@ class BackupRestorer(
}
}
private fun CoroutineScope.restoreExtensionRepos(
backupExtensionRepo: List<BackupExtensionRepos>,
private fun CoroutineScope.restoreExtensionStores(
backupExtensionStores: List<BackupExtensionStore>,
) = launch {
backupExtensionRepo
backupExtensionStores
.forEach {
ensureActive()
try {
extensionRepoRestorer(it)
extensionStoreRestorer(it)
} 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
// KMK -->
with(notifier) {
// KMK <--
showRestoreProgress(
context.stringResource(MR.strings.extensionRepo_settings),
context.stringResource(MR.strings.extensionStores),
restoreProgress,
restoreAmount,
isSync,

View file

@ -9,7 +9,7 @@ data class RestoreOptions(
val libraryEntries: Boolean = true,
val categories: Boolean = true,
val appSettings: Boolean = true,
val extensionRepoSettings: Boolean = true,
val extensionStores: Boolean = true,
val sourceSettings: Boolean = true,
// SY -->
val savedSearchesFeeds: Boolean = true,
@ -20,7 +20,7 @@ data class RestoreOptions(
libraryEntries,
categories,
appSettings,
extensionRepoSettings,
extensionStores,
sourceSettings,
// SY -->
savedSearchesFeeds,
@ -31,7 +31,7 @@ data class RestoreOptions(
libraryEntries ||
categories ||
appSettings ||
extensionRepoSettings ||
extensionStores ||
sourceSettings /* SY --> */ ||
savedSearchesFeeds /* SY <-- */
@ -53,9 +53,9 @@ data class RestoreOptions(
setter = { options, enabled -> options.copy(appSettings = enabled) },
),
Entry(
label = MR.strings.extensionRepo_settings,
getter = RestoreOptions::extensionRepoSettings,
setter = { options, enabled -> options.copy(extensionRepoSettings = enabled) },
label = MR.strings.extensionStores,
getter = RestoreOptions::extensionStores,
setter = { options, enabled -> options.copy(extensionStores = enabled) },
),
Entry(
label = MR.strings.source_settings,
@ -77,7 +77,7 @@ data class RestoreOptions(
libraryEntries = array[0],
categories = array[1],
appSettings = array[2],
extensionRepoSettings = array[3],
extensionStores = array[3],
sourceSettings = array[4],
// SY -->
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,
tracking = syncOptions.tracking,
history = syncOptions.history,
extensionRepoSettings = syncOptions.extensionRepoSettings,
extensionStores = syncOptions.extensionStores,
appSettings = syncOptions.appSettings,
sourceSettings = syncOptions.sourceSettings,
privateSettings = syncOptions.privateSettings,
@ -107,8 +107,8 @@ class SyncManager(
backupCategories = backupCreator.backupCategories(backupOptions),
backupSources = backupCreator.backupSources(backupManga),
backupPreferences = backupCreator.backupAppPreferences(backupOptions),
backupExtensionRepo = backupCreator.backupExtensionRepos(backupOptions),
backupSourcePreferences = backupCreator.backupSourcePreferences(backupOptions),
backupExtensionStores = backupCreator.backupExtensionStores(backupOptions),
// SY -->
backupSavedSearches = backupCreator.backupSavedSearches(backupOptions),
@ -192,7 +192,7 @@ class SyncManager(
backupSources = remoteBackup.backupSources,
backupPreferences = remoteBackup.backupPreferences,
backupSourcePreferences = remoteBackup.backupSourcePreferences,
backupExtensionRepo = remoteBackup.backupExtensionRepo,
backupExtensionStores = remoteBackup.backupExtensionStores,
// SY -->
backupSavedSearches = remoteBackup.backupSavedSearches,
@ -222,7 +222,7 @@ class SyncManager(
appSettings = true,
sourceSettings = 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
// Also clear isObsolete and set new repo Name if needed
val hasUpdate = extension.updateExists(availableExt)
// KMK -->
installedExtensionsMap[pkgName] = extension.copy(
hasUpdate = hasUpdate,
repoUrl = availableExt.repoUrl,
// KMK -->
store = availableExt.store,
isObsolete = false,
repoName = extension.repoName ?: availableExt.repoName,
// KMK <--
storeName = extension.storeName ?: availableExt.storeName,
)
// KMK <--
changed = true
}
}
@ -298,7 +298,7 @@ class ExtensionManager(
* @param extension The extension to be installed.
*/
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.LoadResult
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.ExhPreferences
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
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 mihon.domain.extension.interactor.UpdateExtensionStores
import mihon.domain.extension.repository.ExtensionStoreRepository
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
@ -32,19 +21,16 @@ import kotlin.time.Duration.Companion.days
internal class ExtensionApi {
private val networkService: NetworkHelper by injectLazy()
private val repository: ExtensionStoreRepository by injectLazy()
private val preferenceStore: PreferenceStore by injectLazy()
private val getExtensionRepo: GetExtensionRepo by injectLazy()
private val updateExtensionRepo: UpdateExtensionRepo by injectLazy()
private val updateExtensionStores: UpdateExtensionStores by injectLazy()
private val extensionManager: ExtensionManager by injectLazy()
// SY -->
private val sourcePreferences: SourcePreferences by injectLazy()
// SY <--
private val json: Json by injectLazy()
private val lastExtCheck: Preference<Long> by lazy {
preferenceStore.getLong(Preference.appStateKey("last_ext_check"), 0)
}
@ -54,38 +40,12 @@ internal class ExtensionApi {
val disabledRepos = sourcePreferences.disabledRepos().get()
// KMK <--
return withIOContext {
getExtensionRepo.getAll()
repository.fetchExtensions(
// KMK -->
.filterNot { it.baseUrl in disabledRepos }
// 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,
disabledRepos,
// KMK <--
)
}
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e) { "Failed to get extensions from $repoBaseUrl" }
emptyList()
}
}
suspend fun checkForUpdates(
@ -99,8 +59,7 @@ internal class ExtensionApi {
return null
}
// Update extension repo details
updateExtensionRepo.awaitAll()
updateExtensionStores()
val extensions = if (fromAvailableExtensionList) {
extensionManager.availableExtensionsFlow.value
@ -138,47 +97,6 @@ internal class ExtensionApi {
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 -->
private fun Extension.isBlacklisted(
blacklistEnabled: Boolean = sourcePreferences.enableSourceBlacklist().get(),
@ -194,32 +112,3 @@ internal class ExtensionApi {
}
// 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.runBlocking
import logcat.LogPriority
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
import mihon.domain.extensionrepo.model.ExtensionRepo
import mihon.domain.extension.interactor.GetExtensionStores
import mihon.domain.extension.model.ExtensionStore
import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.injectLazy
import java.io.File
@ -46,7 +46,7 @@ internal object ExtensionLoader {
private val trustExtension: TrustExtension by injectLazy()
// KMK -->
private val getExtensionRepo: GetExtensionRepo by injectLazy()
private val getExtensionStores: GetExtensionStores by injectLazy()
// KMK <--
private val loadNsfwSource by lazy {
@ -169,7 +169,7 @@ internal object ExtensionLoader {
// Load each extension concurrently and wait for completion
return runBlocking {
// KMK -->
val extRepos = getExtensionRepo.getAll()
val extStores = getExtensionStores.get()
// KMK <--
val deferred = extPkgs.map {
async {
@ -177,7 +177,7 @@ internal object ExtensionLoader {
context,
it,
// KMK -->
extRepos,
extStores,
// KMK <--
)
}
@ -228,7 +228,7 @@ internal object ExtensionLoader {
isShared = true,
)
}
} catch (error: PackageManager.NameNotFoundException) {
} catch (_: PackageManager.NameNotFoundException) {
null
}
@ -245,11 +245,11 @@ internal object ExtensionLoader {
context: Context,
extensionInfo: ExtensionInfo,
// KMK -->
extRepos: List<ExtensionRepo>? = null,
extStores: List<ExtensionStore>? = null,
// KMK <--
): LoadResult {
// KMK -->
val repos = extRepos ?: getExtensionRepo.getAll()
val stores = extStores ?: getExtensionStores.get()
// KMK <--
val pkgManager = context.packageManager
val pkgInfo = extensionInfo.packageInfo
@ -288,10 +288,10 @@ internal object ExtensionLoader {
libVersion,
signatures.last(),
// KMK -->
repoName = repos.firstOrNull { repo ->
signatures.all { it == repo.signingKeyFingerprint }
}?.let { repo ->
repo.shortName.takeIf { !it.isNullOrBlank() } ?: repo.name
storeName = stores.firstOrNull { store ->
signatures.all { it == store.signingKey }
}?.let { store ->
store.badgeLabel.takeIf(String::isNotBlank) ?: store.name
},
// KMK <--
)
@ -358,10 +358,10 @@ internal object ExtensionLoader {
isShared = extensionInfo.isShared,
// KMK -->
signatureHash = signatures.last(),
repoName = repos.firstOrNull { repo ->
signatures.all { it == repo.signingKeyFingerprint }
}?.let { repo ->
repo.shortName.takeIf { !it.isNullOrBlank() } ?: repo.name
storeName = stores.firstOrNull { store ->
signatures.all { it == store.signingKey }
}?.let { store ->
store.badgeLabel.takeIf(String::isNotBlank) ?: store.name
},
// 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
* back from the [ExtensionFilterScreen] or from the [ExtensionReposScreen].
* back from the [ExtensionFilterScreen] or from the [ExtensionStoresScreen].
* Not really desired but let's accept it.
*/
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.components.AppBar
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.ui.browse.extension.details.ExtensionDetailsScreen
import eu.kanade.tachiyomi.ui.webview.WebViewScreen
@ -62,8 +62,8 @@ fun extensionsTab(
onClick = { navigator.push(ExtensionFilterScreen()) },
),
AppBar.OverflowAction(
title = stringResource(MR.strings.label_extension_repos),
onClick = { navigator.push(ExtensionReposScreen()) },
title = stringResource(MR.strings.extensionStores),
onClick = { navigator.push(ExtensionStoresScreen()) },
),
),
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.about.AboutScreen.Companion.getReleaseNotes
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.util.AssistContentScreen
import eu.kanade.presentation.util.DefaultNavigatorScreenTransition
@ -693,11 +693,11 @@ class MainActivity : BaseActivity() {
navigator.popUntilRoot()
navigator.push(RestoreBackupScreen(intent.data.toString()))
}
// Deep link to add extension repo
else if (intent.scheme == "tachiyomi" && intent.data?.host == "add-repo") {
// Deep link to add extension store
else if (intent.isAddExtensionStoreIntent()) {
intent.data?.getQueryParameter("url")?.let { repoUrl ->
navigator.popUntilRoot()
navigator.push(ExtensionReposScreen(repoUrl))
navigator.push(ExtensionStoresScreen(repoUrl))
}
}
null
@ -713,6 +713,11 @@ class MainActivity : BaseActivity() {
return true
}
private fun Intent.isAddExtensionStoreIntent(): Boolean {
return (scheme == "tachiyomi" && data?.host == "add-repo") ||
(scheme == "mihon" && data?.host == "extension-store")
}
companion object {
const val INTENT_SEARCH = "eu.kanade.tachiyomi.SEARCH"
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(),
CategoryPreferencesCleanupMigration(),
RemoveDuplicateReaderPreferenceMigration(),
// KMK -->
DisabledRepoMigration(),
// KMK <--
)

View file

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

View file

@ -25,7 +25,7 @@ android {
kotlin {
compilerOptions {
freeCompilerArgs.add("-opt-in=kotlinx.serialization.ExperimentalSerializationApi")
optIn.add("kotlinx.serialization.ExperimentalSerializationApi")
}
}
@ -34,5 +34,9 @@ dependencies {
implementation(projects.domain)
implementation(projects.core.common)
implementation(kotlinx.serialization.json)
implementation(kotlinx.serialization.json.okio)
implementation(kotlinx.serialization.protobuf)
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 eu.kanade.tachiyomi.source.Source
import mihon.domain.extension.model.ExtensionStore
import tachiyomi.domain.source.model.StubSource
sealed class Extension {
@ -16,7 +17,7 @@ sealed class Extension {
// KMK -->
abstract val signatureHash: String
abstract val repoName: String?
abstract val storeName: String?
// KMK <--
data class Installed(
@ -29,8 +30,8 @@ sealed class Extension {
override val isNsfw: Boolean,
// KMK -->
override val signatureHash: String,
/** Guessing repo name from built-in signatures preset */
override val repoName: String? = null,
/** Guessing store name from built-in signatures preset */
override val storeName: String? = null,
// KMK <--
val pkgFactory: String?,
val sources: List<Source>,
@ -38,7 +39,7 @@ sealed class Extension {
val hasUpdate: Boolean = false,
val isObsolete: Boolean = false,
val isShared: Boolean,
val repoUrl: String? = null,
val store: ExtensionStore? = null,
// SY -->
val isRedundant: Boolean = false,
// SY <--
@ -54,12 +55,12 @@ sealed class Extension {
override val isNsfw: Boolean,
// KMK -->
override val signatureHash: String,
override val repoName: String,
override val storeName: String,
// KMK <--
val sources: List<Source>,
val apkName: String,
val apkUrl: String,
val iconUrl: String,
val repoUrl: String,
val store: ExtensionStore,
) : Extension() {
data class Source(
@ -87,7 +88,7 @@ sealed class Extension {
/* KMK --> */
override /* KMK <-- */ val signatureHash: String,
// KMK -->
override val repoName: String? = null,
override val storeName: String? = null,
// KMK <--
override val lang: String? = null,
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>
<!-- 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_replace_repo">Replace</string>
<string name="action_replace_repo_title">Signing Key Fingerprint Already Exists</string>
<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>
<!-- Extension store -->
<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 -->
<string name="pref_fullscreen">Fullscreen</string>
@ -567,7 +566,6 @@
<string name="backup_choice">What do you want to backup?</string>
<string name="app_settings">App 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="non_library_settings">All read entries</string>
<string name="creating_backup">Creating backup</string>