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

This commit is contained in:
AntsyLich
2026-06-03 06:35:44 +06:00
committed by GitHub
parent 748211c19b
commit b446d2a6b1
64 changed files with 1279 additions and 1218 deletions
+6
View File
@@ -11,6 +11,12 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- `Other` - for technical stuff. - `Other` - for technical stuff.
## [Unreleased] ## [Unreleased]
### Added
- Add support for `tachiyomix` extension index format ([@AntsyLich](https://github.com/AntsyLich)) ([#3349](https://github.com/mihonapp/mihon/pull/3349))
### Changed
- Change all reference of extension repo to extension store ([@AntsyLich](https://github.com/AntsyLich)) ([#3349](https://github.com/mihonapp/mihon/pull/3349))
### Fixed ### Fixed
- Add missing `outlineVariant` color to Nord theme ([@CompileConnected](https://github.com/CompileConnected)) ([#3184](https://github.com/mihonapp/mihon/pull/3184)) - Add missing `outlineVariant` color to Nord theme ([@CompileConnected](https://github.com/CompileConnected)) ([#3184](https://github.com/mihonapp/mihon/pull/3184))
+13 -2
View File
@@ -60,8 +60,8 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- Deep link to add repos --> <!-- Legacy deeplink to add extension store -->
<intent-filter android:label="@string/action_add_repo"> <intent-filter android:label="@string/action_addExtensionStore">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
@@ -71,6 +71,17 @@
<data android:host="add-repo" /> <data android:host="add-repo" />
</intent-filter> </intent-filter>
<!-- Deeplink to add extension store -->
<intent-filter android:label="@string/action_addExtensionStore">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mihon" />
<data android:host="extension-store" />
</intent-filter>
<!-- Open backup files --> <!-- Open backup files -->
<intent-filter android:label="@string/pref_restore_backup"> <intent-filter android:label="@string/pref_restore_backup">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
@@ -25,16 +25,15 @@ import eu.kanade.domain.track.interactor.AddTracks
import eu.kanade.domain.track.interactor.RefreshTracks import eu.kanade.domain.track.interactor.RefreshTracks
import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack
import eu.kanade.domain.track.interactor.TrackChapter import eu.kanade.domain.track.interactor.TrackChapter
import mihon.data.repository.ExtensionRepoRepositoryImpl import mihon.data.extension.repository.ExtensionStoreRepositoryImpl
import mihon.data.extension.service.ExtensionStoreService
import mihon.domain.chapter.interactor.FilterChaptersForDownload import mihon.domain.chapter.interactor.FilterChaptersForDownload
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo import mihon.domain.extension.interactor.AddExtensionStore
import mihon.domain.extensionrepo.interactor.DeleteExtensionRepo import mihon.domain.extension.interactor.GetExtensionStoreCountAsFlow
import mihon.domain.extensionrepo.interactor.GetExtensionRepo import mihon.domain.extension.interactor.GetExtensionStores
import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount import mihon.domain.extension.interactor.RemoveExtensionStore
import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo import mihon.domain.extension.interactor.UpdateExtensionStores
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo import mihon.domain.extension.repository.ExtensionStoreRepository
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import mihon.domain.extensionrepo.service.ExtensionRepoService
import mihon.domain.migration.usecases.MigrateMangaUseCase import mihon.domain.migration.usecases.MigrateMangaUseCase
import mihon.domain.upcoming.interactor.GetUpcomingManga import mihon.domain.upcoming.interactor.GetUpcomingManga
import tachiyomi.data.category.CategoryRepositoryImpl import tachiyomi.data.category.CategoryRepositoryImpl
@@ -195,14 +194,14 @@ class DomainModule : InjektModule {
addFactory { ToggleSourcePin(get()) } addFactory { ToggleSourcePin(get()) }
addFactory { TrustExtension(get(), get()) } addFactory { TrustExtension(get(), get()) }
addSingletonFactory<ExtensionRepoRepository> { ExtensionRepoRepositoryImpl(get()) } addSingletonFactory { ExtensionStoreService(get(), get(), get()) }
addFactory { ExtensionRepoService(get(), get()) } addSingletonFactory<ExtensionStoreRepository> { ExtensionStoreRepositoryImpl(get(), get()) }
addFactory { GetExtensionRepo(get()) } addFactory { AddExtensionStore(get()) }
addFactory { GetExtensionRepoCount(get()) } addFactory { GetExtensionStoreCountAsFlow(get()) }
addFactory { CreateExtensionRepo(get(), get()) } addFactory { GetExtensionStores(get()) }
addFactory { DeleteExtensionRepo(get()) } addFactory { RemoveExtensionStore(get()) }
addFactory { ReplaceExtensionRepo(get()) } addFactory { UpdateExtensionStores(get()) }
addFactory { UpdateExtensionRepo(get(), get()) }
addFactory { ToggleIncognito(get()) } addFactory { ToggleIncognito(get()) }
addFactory { GetIncognitoState(get(), get(), get()) } addFactory { GetIncognitoState(get(), get(), get()) }
} }
@@ -17,12 +17,8 @@ class GetExtensionLanguages(
) { enabledLanguage, availableExtensions -> ) { enabledLanguage, availableExtensions ->
availableExtensions availableExtensions
.flatMap { ext -> .flatMap { ext ->
if (ext.sources.isEmpty()) {
listOf(ext.lang)
} else {
ext.sources.map { it.lang } ext.sources.map { it.lang }
} }
}
.distinct() .distinct()
.sortedWith( .sortedWith(
compareBy<String> { it !in enabledLanguage }.then(LocaleHelper.comparator), compareBy<String> { it !in enabledLanguage }.then(LocaleHelper.comparator),
@@ -39,9 +39,6 @@ class GetExtensionsByType(
(showNsfwSources || !extension.isNsfw) (showNsfwSources || !extension.isNsfw)
} }
.flatMap { ext -> .flatMap { ext ->
if (ext.sources.isEmpty()) {
return@flatMap if (ext.lang in enabledLanguages) listOf(ext) else emptyList()
}
ext.sources.filter { it.lang in enabledLanguages } ext.sources.filter { it.lang in enabledLanguages }
.map { .map {
ext.copy( ext.copy(
@@ -3,16 +3,16 @@ package eu.kanade.domain.extension.interactor
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import androidx.core.content.pm.PackageInfoCompat import androidx.core.content.pm.PackageInfoCompat
import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.source.service.SourcePreferences
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository import mihon.domain.extension.repository.ExtensionStoreRepository
import tachiyomi.core.common.preference.getAndSet import tachiyomi.core.common.preference.getAndSet
class TrustExtension( class TrustExtension(
private val extensionRepoRepository: ExtensionRepoRepository, private val repository: ExtensionStoreRepository,
private val preferences: SourcePreferences, private val preferences: SourcePreferences,
) { ) {
suspend fun isTrusted(pkgInfo: PackageInfo, fingerprints: List<String>): Boolean { suspend fun isTrusted(pkgInfo: PackageInfo, fingerprints: List<String>): Boolean {
val trustedFingerprints = extensionRepoRepository.getAll().map { it.signingKeyFingerprint }.toHashSet() val trustedFingerprints = repository.getAll().map { it.signingKey }.toHashSet()
val key = "${pkgInfo.packageName}:${PackageInfoCompat.getLongVersionCode(pkgInfo)}:${fingerprints.last()}" val key = "${pkgInfo.packageName}:${PackageInfoCompat.getLongVersionCode(pkgInfo)}:${fingerprints.last()}"
return trustedFingerprints.any { fingerprints.contains(it) } || key in preferences.trustedExtensions.get() return trustedFingerprints.any { fingerprints.contains(it) } || key in preferences.trustedExtensions.get()
} }
@@ -80,12 +80,12 @@ fun ExtensionDetailsScreen(
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val url = remember(state.extension) { val url = remember(state.extension) {
val regex = """https://raw.githubusercontent.com/(.+?)/(.+?)/.+""".toRegex() val regex = """https://raw.githubusercontent.com/(.+?)/(.+?)/.+""".toRegex()
regex.find(state.extension?.repoUrl.orEmpty()) regex.find(state.extension?.store?.indexUrl.orEmpty())
?.let { ?.let {
val (user, repo) = it.destructured val (user, repo) = it.destructured
"https://github.com/$user/$repo" "https://github.com/$user/$repo"
} }
?: state.extension?.repoUrl ?: state.extension?.store?.indexUrl
} }
Scaffold( Scaffold(
@@ -248,14 +248,17 @@ private fun DetailsHeader(
if (extension is Extension.Installed) { if (extension is Extension.Installed) {
append("\n\n") append("\n\n")
append( appendLine(
""" """
Update available: ${extension.hasUpdate} Update available: ${extension.hasUpdate}
Obsolete: ${extension.isObsolete} Obsolete: ${extension.isObsolete}
Shared: ${extension.isShared} Shared: ${extension.isShared}
Repository: ${extension.repoUrl}
""".trimIndent(), """.trimIndent(),
) )
val store = extension.store
if (store != null) {
append("Repository: ${store.indexUrl}")
}
} }
} }
context.copyToClipboard("Extension Debug information", extDebugInfo) context.copyToClipboard("Extension Debug information", extDebugInfo)
@@ -47,7 +47,7 @@ import eu.kanade.presentation.browse.components.BaseBrowseItem
import eu.kanade.presentation.browse.components.ExtensionIcon import eu.kanade.presentation.browse.components.ExtensionIcon
import eu.kanade.presentation.components.WarningBanner import eu.kanade.presentation.components.WarningBanner
import eu.kanade.presentation.manga.components.DotSeparatorNoSpaceText import eu.kanade.presentation.manga.components.DotSeparatorNoSpaceText
import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
import eu.kanade.presentation.util.animateItemFastScroll import eu.kanade.presentation.util.animateItemFastScroll
import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
@@ -106,9 +106,9 @@ fun ExtensionScreen(
modifier = Modifier.padding(contentPadding), modifier = Modifier.padding(contentPadding),
actions = persistentListOf( actions = persistentListOf(
EmptyScreenAction( EmptyScreenAction(
stringRes = MR.strings.label_extension_repos, stringRes = MR.strings.extensionStores,
icon = Icons.Outlined.Settings, icon = Icons.Outlined.Settings,
onClick = { navigator.push(ExtensionReposScreen()) }, onClick = { navigator.push(ExtensionStoresScreen()) },
), ),
), ),
) )
@@ -11,10 +11,10 @@ import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.presentation.more.settings.Preference import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount import mihon.domain.extension.interactor.GetExtensionStoreCountAsFlow
import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.pluralStringResource import tachiyomi.presentation.core.i18n.pluralStringResource
@@ -34,9 +34,9 @@ object SettingsBrowseScreen : SearchableSettings {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val sourcePreferences = remember { Injekt.get<SourcePreferences>() } val sourcePreferences = remember { Injekt.get<SourcePreferences>() }
val getExtensionRepoCount = remember { Injekt.get<GetExtensionRepoCount>() } val getExtensionStoreCountAsFlow = remember { Injekt.get<GetExtensionStoreCountAsFlow>() }
val reposCount by getExtensionRepoCount.subscribe().collectAsState(0) val reposCount by getExtensionStoreCountAsFlow().collectAsState(0)
return listOf( return listOf(
Preference.PreferenceGroup( Preference.PreferenceGroup(
@@ -47,10 +47,10 @@ object SettingsBrowseScreen : SearchableSettings {
title = stringResource(MR.strings.pref_hide_in_library_items), title = stringResource(MR.strings.pref_hide_in_library_items),
), ),
Preference.PreferenceItem.TextPreference( Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.label_extension_repos), title = stringResource(MR.strings.extensionStores),
subtitle = pluralStringResource(MR.plurals.num_repos, reposCount, reposCount), subtitle = pluralStringResource(MR.plurals.num_repos, reposCount.toInt(), reposCount),
onClick = { onClick = {
navigator.push(ExtensionReposScreen()) navigator.push(ExtensionStoresScreen())
}, },
), ),
), ),
@@ -1,96 +0,0 @@
package eu.kanade.presentation.more.settings.screen.browse
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.util.Screen
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.presentation.core.screens.LoadingScreen
class ExtensionReposScreen(
private val url: String? = null,
) : Screen() {
@Composable
override fun Content() {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { ExtensionReposScreenModel() }
val state by screenModel.state.collectAsState()
LaunchedEffect(url) {
url?.let { screenModel.showDialog(RepoDialog.Confirm(it)) }
}
if (state is RepoScreenState.Loading) {
LoadingScreen()
return
}
val successState = state as RepoScreenState.Success
ExtensionReposScreen(
state = successState,
onClickCreate = { screenModel.showDialog(RepoDialog.Create) },
onOpenWebsite = { context.openInBrowser(it.website) },
onClickDelete = { screenModel.showDialog(RepoDialog.Delete(it)) },
onClickRefresh = { screenModel.refreshRepos() },
navigateUp = navigator::pop,
)
when (val dialog = successState.dialog) {
null -> {}
is RepoDialog.Create -> {
ExtensionRepoCreateDialog(
onDismissRequest = screenModel::dismissDialog,
onCreate = { screenModel.createRepo(it) },
repoUrls = successState.repos.map { it.baseUrl }.toImmutableSet(),
)
}
is RepoDialog.Delete -> {
ExtensionRepoDeleteDialog(
onDismissRequest = screenModel::dismissDialog,
onDelete = { screenModel.deleteRepo(dialog.repo) },
repo = dialog.repo,
)
}
is RepoDialog.Conflict -> {
ExtensionRepoConflictDialog(
onDismissRequest = screenModel::dismissDialog,
onMigrate = { screenModel.replaceRepo(dialog.newRepo) },
oldRepo = dialog.oldRepo,
newRepo = dialog.newRepo,
)
}
is RepoDialog.Confirm -> {
ExtensionRepoConfirmDialog(
onDismissRequest = screenModel::dismissDialog,
onCreate = { screenModel.createRepo(dialog.url) },
repo = dialog.url,
)
}
}
LaunchedEffect(Unit) {
screenModel.events.collectLatest { event ->
if (event is RepoEvent.LocalizedMessage) {
context.toast(event.stringRes)
}
}
}
}
}
@@ -1,150 +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.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.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(),
) : 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(),
)
}
}
}
}
/**
* 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) {
screenModelScope.launchIO {
deleteExtensionRepo.await(baseUrl)
extensionManager.findAvailableExtensions()
}
}
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,
) : RepoScreenState() {
val isEmpty: Boolean
get() = repos.isEmpty()
}
}
@@ -0,0 +1,85 @@
package eu.kanade.presentation.more.settings.screen.browse
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.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 tachiyomi.presentation.core.screens.LoadingScreen
class ExtensionStoresScreen(
private val url: String? = null,
) : Screen() {
@Composable
override fun Content() {
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { ExtensionStoresScreenModel() }
val state by screenModel.state.collectAsState()
LaunchedEffect(url) {
url?.let { screenModel.addFromDeeplink(url) }
}
if (state is ExtensionStoreScreenState.Loading) {
LoadingScreen()
return
}
val successState = state as ExtensionStoreScreenState.Success
ExtensionStoresScreen(
state = successState,
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) },
onClickDelete = { screenModel.showDialog(ExtensionStoreDialog.Delete(it)) },
onClickRefresh = { screenModel.refreshRepos() },
navigateUp = navigator::pop,
)
when (val dialog = successState.dialog) {
null -> {}
is ExtensionStoreDialog.Create -> {
ExtensionStoreCreateDialog(
onDismissRequest = screenModel::dismissDialog,
onCreate = { screenModel.createRepo(it) },
storeIndexUrls = successState.stores.map { it.indexUrl }.toSet(),
processing = dialog.processing,
errorMessage = dialog.errorMessage,
)
}
is ExtensionStoreDialog.Delete -> {
ExtensionStoreDeleteDialog(
onDismissRequest = screenModel::dismissDialog,
onDelete = { screenModel.deleteRepo(dialog.store.indexUrl) },
storeName = dialog.store.name,
storeIndexUrl = dialog.store.indexUrl,
)
}
is ExtensionStoreDialog.Confirm -> {
ExtensionStoreConfirmDialog(
onDismissRequest = screenModel::dismissDialog,
onCreate = { screenModel.createRepo(dialog.url) },
storeIndexUrl = dialog.url,
storeAlreadyExists = dialog.alreadyExists,
processing = dialog.processing,
errorMessage = dialog.errorMessage,
)
}
}
}
}
@@ -0,0 +1,165 @@
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.tachiyomi.extension.ExtensionManager
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
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(),
) : 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)
is ExtensionStoreScreenState.Success -> it.copy(stores = stores)
}
}
}
}
}
/**
* Creates and adds a new repo to the database.
*
* @param baseUrl The baseUrl of the repo to create.
*/
fun createRepo(baseUrl: String) {
screenModelScope.launch {
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(baseUrl)
.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(baseUrl: String) {
screenModelScope.launchIO {
removeExtensionStore(baseUrl)
extensionManager.findAvailableExtensions()
}
}
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,
) : ExtensionStoreScreenState() {
val isEmpty: Boolean
get() = stores.isEmpty()
}
}
@@ -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))
}
},
)
}
@@ -9,9 +9,9 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Label import androidx.compose.material.icons.automirrored.outlined.Label
import androidx.compose.material.icons.automirrored.outlined.OpenInNew
import androidx.compose.material.icons.outlined.ContentCopy import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -20,21 +20,22 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import mihon.domain.extension.model.ExtensionStore
import eu.kanade.tachiyomi.util.system.copyToClipboard
import kotlinx.collections.immutable.ImmutableSet
import mihon.domain.extensionrepo.model.ExtensionRepo
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.icons.CustomIcons
import tachiyomi.presentation.core.icons.Discord
@Composable @Composable
fun ExtensionReposContent( fun ExtensionStoresContent(
repos: ImmutableSet<ExtensionRepo>, repos: List<ExtensionStore>,
lazyListState: LazyListState, lazyListState: LazyListState,
paddingValues: PaddingValues, paddingValues: PaddingValues,
onOpenWebsite: (ExtensionRepo) -> Unit, onCopy: (ExtensionStore) -> Unit,
onClickDelete: (String) -> Unit, onOpenWebsite: (ExtensionStore) -> Unit,
onOpenDiscord: (ExtensionStore) -> Unit,
onClickDelete: (ExtensionStore) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LazyColumn( LazyColumn(
@@ -45,11 +46,13 @@ fun ExtensionReposContent(
) { ) {
repos.forEach { repos.forEach {
item { item {
ExtensionRepoListItem( ExtensionStoresListItem(
modifier = Modifier.animateItem(), modifier = Modifier.animateItem(),
repo = it, store = it,
onOpenWebsite = { onOpenWebsite(it) }, onOpenWebsite = { onOpenWebsite(it) },
onDelete = { onClickDelete(it.baseUrl) }, onOpenDiscord = { onOpenDiscord(it) },
onCopy = { onCopy(it) },
onDelete = { onClickDelete(it) },
) )
} }
} }
@@ -57,14 +60,14 @@ fun ExtensionReposContent(
} }
@Composable @Composable
private fun ExtensionRepoListItem( private fun ExtensionStoresListItem(
repo: ExtensionRepo, store: ExtensionStore,
onOpenWebsite: () -> Unit, onOpenWebsite: () -> Unit,
onOpenDiscord: () -> Unit,
onCopy: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current
ElevatedCard( ElevatedCard(
modifier = modifier, modifier = modifier,
) { ) {
@@ -80,7 +83,7 @@ private fun ExtensionRepoListItem(
) { ) {
Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null) Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null)
Text( Text(
text = repo.name, text = store.name,
modifier = Modifier.padding(start = MaterialTheme.padding.medium), modifier = Modifier.padding(start = MaterialTheme.padding.medium),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
) )
@@ -92,17 +95,21 @@ private fun ExtensionRepoListItem(
) { ) {
IconButton(onClick = onOpenWebsite) { IconButton(onClick = onOpenWebsite) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Outlined.OpenInNew, imageVector = Icons.Outlined.Public,
contentDescription = stringResource(MR.strings.action_open_in_browser), contentDescription = stringResource(MR.strings.action_open_in_browser),
) )
} }
IconButton( if (store.contact.discord != null) {
onClick = { IconButton(onClick = onOpenDiscord) {
val url = "${repo.baseUrl}/index.min.json" Icon(
context.copyToClipboard(url, url) imageVector = CustomIcons.Discord,
}, contentDescription = null,
) { )
}
}
IconButton(onClick = onCopy) {
Icon( Icon(
imageVector = Icons.Outlined.ContentCopy, imageVector = Icons.Outlined.ContentCopy,
contentDescription = stringResource(MR.strings.action_copy_to_clipboard), contentDescription = stringResource(MR.strings.action_copy_to_clipboard),
@@ -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))
}
},
)
}
@@ -1,5 +1,3 @@
@file:JvmName("ExtensionReposScreenKt")
package eu.kanade.presentation.more.settings.screen.browse.components package eu.kanade.presentation.more.settings.screen.browse.components
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
@@ -14,8 +12,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import eu.kanade.presentation.category.components.CategoryFloatingActionButton import eu.kanade.presentation.category.components.CategoryFloatingActionButton
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.more.settings.screen.browse.RepoScreenState import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoreScreenState
import mihon.domain.extensionrepo.model.ExtensionRepo import mihon.domain.extension.model.ExtensionStore
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
@@ -25,11 +23,13 @@ import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.util.plus import tachiyomi.presentation.core.util.plus
@Composable @Composable
fun ExtensionReposScreen( fun ExtensionStoresScreen(
state: RepoScreenState.Success, state: ExtensionStoreScreenState.Success,
onClickCreate: () -> Unit, onClickCreate: () -> Unit,
onOpenWebsite: (ExtensionRepo) -> Unit, onCopy: (ExtensionStore) -> Unit,
onClickDelete: (String) -> Unit, onOpenWebsite: (ExtensionStore) -> Unit,
onOpenDiscord: (ExtensionStore) -> Unit,
onClickDelete: (ExtensionStore) -> Unit,
onClickRefresh: () -> Unit, onClickRefresh: () -> Unit,
navigateUp: () -> Unit, navigateUp: () -> Unit,
) { ) {
@@ -38,7 +38,7 @@ fun ExtensionReposScreen(
topBar = { scrollBehavior -> topBar = { scrollBehavior ->
AppBar( AppBar(
navigateUp = navigateUp, navigateUp = navigateUp,
title = stringResource(MR.strings.label_extension_repos), title = stringResource(MR.strings.extensionStores),
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
actions = { actions = {
IconButton(onClick = onClickRefresh) { IconButton(onClick = onClickRefresh) {
@@ -59,18 +59,20 @@ fun ExtensionReposScreen(
) { paddingValues -> ) { paddingValues ->
if (state.isEmpty) { if (state.isEmpty) {
EmptyScreen( EmptyScreen(
MR.strings.information_empty_repos, MR.strings.extensionStoresScreen_emptyLabel,
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
) )
return@Scaffold return@Scaffold
} }
ExtensionReposContent( ExtensionStoresContent(
repos = state.repos, repos = state.stores,
lazyListState = lazyListState, lazyListState = lazyListState,
paddingValues = paddingValues + topSmallPaddingValues + paddingValues = paddingValues + topSmallPaddingValues +
PaddingValues(horizontal = MaterialTheme.padding.medium), PaddingValues(horizontal = MaterialTheme.padding.medium),
onCopy = onCopy,
onOpenWebsite = onOpenWebsite, onOpenWebsite = onOpenWebsite,
onOpenDiscord = onOpenDiscord,
onClickDelete = onClickDelete, onClickDelete = onClickDelete,
) )
} }
@@ -6,13 +6,13 @@ import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.backup.BackupFileValidator import eu.kanade.tachiyomi.data.backup.BackupFileValidator
import eu.kanade.tachiyomi.data.backup.create.creators.CategoriesBackupCreator import eu.kanade.tachiyomi.data.backup.create.creators.CategoriesBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.ExtensionRepoBackupCreator import eu.kanade.tachiyomi.data.backup.create.creators.ExtensionStoresBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.MangaBackupCreator import eu.kanade.tachiyomi.data.backup.create.creators.MangaBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.PreferenceBackupCreator import eu.kanade.tachiyomi.data.backup.create.creators.PreferenceBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.SourcesBackupCreator import eu.kanade.tachiyomi.data.backup.create.creators.SourcesBackupCreator
import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.BackupCategory import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionRepos import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
import eu.kanade.tachiyomi.data.backup.models.BackupManga import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference import eu.kanade.tachiyomi.data.backup.models.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSource import eu.kanade.tachiyomi.data.backup.models.BackupSource
@@ -49,7 +49,7 @@ class BackupCreator(
private val categoriesBackupCreator: CategoriesBackupCreator = CategoriesBackupCreator(), private val categoriesBackupCreator: CategoriesBackupCreator = CategoriesBackupCreator(),
private val mangaBackupCreator: MangaBackupCreator = MangaBackupCreator(), private val mangaBackupCreator: MangaBackupCreator = MangaBackupCreator(),
private val preferenceBackupCreator: PreferenceBackupCreator = PreferenceBackupCreator(), private val preferenceBackupCreator: PreferenceBackupCreator = PreferenceBackupCreator(),
private val extensionRepoBackupCreator: ExtensionRepoBackupCreator = ExtensionRepoBackupCreator(), private val extensionStoresBackupCreator: ExtensionStoresBackupCreator = ExtensionStoresBackupCreator(),
private val sourcesBackupCreator: SourcesBackupCreator = SourcesBackupCreator(), private val sourcesBackupCreator: SourcesBackupCreator = SourcesBackupCreator(),
) { ) {
@@ -85,7 +85,7 @@ class BackupCreator(
backupCategories = backupCategories(options), backupCategories = backupCategories(options),
backupSources = backupSources(backupManga), backupSources = backupSources(backupManga),
backupPreferences = backupAppPreferences(options), backupPreferences = backupAppPreferences(options),
backupExtensionRepo = backupExtensionRepos(options), backupExtensionStores = backupExtensionStores(options),
backupSourcePreferences = backupSourcePreferences(options), backupSourcePreferences = backupSourcePreferences(options),
) )
@@ -141,10 +141,10 @@ class BackupCreator(
return preferenceBackupCreator.createApp(includePrivatePreferences = options.privateSettings) return preferenceBackupCreator.createApp(includePrivatePreferences = options.privateSettings)
} }
private suspend fun backupExtensionRepos(options: BackupOptions): List<BackupExtensionRepos> { private suspend fun backupExtensionStores(options: BackupOptions): List<BackupExtensionStore> {
if (!options.extensionRepoSettings) return emptyList() if (!options.extensionStores) return emptyList()
return extensionRepoBackupCreator() return extensionStoresBackupCreator()
} }
private fun backupSourcePreferences(options: BackupOptions): List<BackupSourcePreferences> { private fun backupSourcePreferences(options: BackupOptions): List<BackupSourcePreferences> {
@@ -12,7 +12,7 @@ data class BackupOptions(
val history: Boolean = true, val history: Boolean = true,
val readEntries: Boolean = true, val readEntries: Boolean = true,
val appSettings: Boolean = true, val appSettings: Boolean = true,
val extensionRepoSettings: Boolean = true, val extensionStores: Boolean = true,
val sourceSettings: Boolean = true, val sourceSettings: Boolean = true,
val privateSettings: Boolean = false, val privateSettings: Boolean = false,
) { ) {
@@ -25,12 +25,12 @@ data class BackupOptions(
history, history,
readEntries, readEntries,
appSettings, appSettings,
extensionRepoSettings, extensionStores,
sourceSettings, sourceSettings,
privateSettings, privateSettings,
) )
fun canCreate() = libraryEntries || categories || appSettings || extensionRepoSettings || sourceSettings fun canCreate() = libraryEntries || categories || appSettings || extensionStores || sourceSettings
companion object { companion object {
val libraryOptions = persistentListOf( val libraryOptions = persistentListOf(
@@ -77,9 +77,9 @@ data class BackupOptions(
setter = { options, enabled -> options.copy(appSettings = enabled) }, setter = { options, enabled -> options.copy(appSettings = enabled) },
), ),
Entry( Entry(
label = MR.strings.extensionRepo_settings, label = MR.strings.extensionStores,
getter = BackupOptions::extensionRepoSettings, getter = BackupOptions::extensionStores,
setter = { options, enabled -> options.copy(extensionRepoSettings = enabled) }, setter = { options, enabled -> options.copy(extensionStores = enabled) },
), ),
Entry( Entry(
label = MR.strings.source_settings, label = MR.strings.source_settings,
@@ -102,7 +102,7 @@ data class BackupOptions(
history = array[4], history = array[4],
readEntries = array[5], readEntries = array[5],
appSettings = array[6], appSettings = array[6],
extensionRepoSettings = array[7], extensionStores = array[7],
sourceSettings = array[8], sourceSettings = array[8],
privateSettings = array[9], privateSettings = array[9],
) )
@@ -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)
}
}
@@ -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)
}
}
@@ -11,5 +11,5 @@ data class Backup(
@ProtoNumber(101) var backupSources: List<BackupSource> = emptyList(), @ProtoNumber(101) var backupSources: List<BackupSource> = emptyList(),
@ProtoNumber(104) var backupPreferences: List<BackupPreference> = emptyList(), @ProtoNumber(104) var backupPreferences: List<BackupPreference> = emptyList(),
@ProtoNumber(105) var backupSourcePreferences: List<BackupSourcePreferences> = emptyList(), @ProtoNumber(105) var backupSourcePreferences: List<BackupSourcePreferences> = emptyList(),
@ProtoNumber(106) var backupExtensionRepo: List<BackupExtensionRepos> = emptyList(), @ProtoNumber(106) var backupExtensionStores: List<BackupExtensionStore> = emptyList(),
) )
@@ -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,
)
}
@@ -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,
)
}
@@ -5,12 +5,12 @@ import android.net.Uri
import eu.kanade.tachiyomi.data.backup.BackupDecoder import eu.kanade.tachiyomi.data.backup.BackupDecoder
import eu.kanade.tachiyomi.data.backup.BackupNotifier import eu.kanade.tachiyomi.data.backup.BackupNotifier
import eu.kanade.tachiyomi.data.backup.models.BackupCategory import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionRepos import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
import eu.kanade.tachiyomi.data.backup.models.BackupManga import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference import eu.kanade.tachiyomi.data.backup.models.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
import eu.kanade.tachiyomi.data.backup.restore.restorers.CategoriesRestorer import eu.kanade.tachiyomi.data.backup.restore.restorers.CategoriesRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.ExtensionRepoRestorer import eu.kanade.tachiyomi.data.backup.restore.restorers.ExtensionStoreRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.MangaRestorer import eu.kanade.tachiyomi.data.backup.restore.restorers.MangaRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.PreferenceRestorer import eu.kanade.tachiyomi.data.backup.restore.restorers.PreferenceRestorer
import eu.kanade.tachiyomi.util.system.createFileInCacheDir import eu.kanade.tachiyomi.util.system.createFileInCacheDir
@@ -32,7 +32,7 @@ class BackupRestorer(
private val categoriesRestorer: CategoriesRestorer = CategoriesRestorer(), private val categoriesRestorer: CategoriesRestorer = CategoriesRestorer(),
private val preferenceRestorer: PreferenceRestorer = PreferenceRestorer(context), private val preferenceRestorer: PreferenceRestorer = PreferenceRestorer(context),
private val extensionRepoRestorer: ExtensionRepoRestorer = ExtensionRepoRestorer(), private val extensionStoreRestorer: ExtensionStoreRestorer = ExtensionStoreRestorer(),
private val mangaRestorer: MangaRestorer = MangaRestorer(), private val mangaRestorer: MangaRestorer = MangaRestorer(),
) { ) {
@@ -79,8 +79,8 @@ class BackupRestorer(
if (options.appSettings) { if (options.appSettings) {
restoreAmount += 1 restoreAmount += 1
} }
if (options.extensionRepoSettings) { if (options.extensionStores) {
restoreAmount += backup.backupExtensionRepo.size restoreAmount += backup.backupExtensionStores.size
} }
if (options.sourceSettings) { if (options.sourceSettings) {
restoreAmount += 1 restoreAmount += 1
@@ -99,8 +99,8 @@ class BackupRestorer(
if (options.libraryEntries) { if (options.libraryEntries) {
restoreManga(backup.backupManga, if (options.categories) backup.backupCategories else emptyList()) restoreManga(backup.backupManga, if (options.categories) backup.backupCategories else emptyList())
} }
if (options.extensionRepoSettings) { if (options.extensionStores) {
restoreExtensionRepos(backup.backupExtensionRepo) restoreExtensionStores(backup.backupExtensionStores)
} }
// TODO: optionally trigger online library + tracker update // TODO: optionally trigger online library + tracker update
@@ -172,22 +172,22 @@ class BackupRestorer(
) )
} }
private fun CoroutineScope.restoreExtensionRepos( private fun CoroutineScope.restoreExtensionStores(
backupExtensionRepo: List<BackupExtensionRepos>, backupExtensionStores: List<BackupExtensionStore>,
) = launch { ) = launch {
backupExtensionRepo backupExtensionStores
.forEach { .forEach {
ensureActive() ensureActive()
try { try {
extensionRepoRestorer(it) extensionStoreRestorer(it)
} catch (e: Exception) { } catch (e: Exception) {
errors.add(Date() to "Error Adding Repo: ${it.name} : ${e.message}") errors.add(Date() to "Error Adding Repo: ${it.name} : ${e.message}")
} }
restoreProgress += 1 restoreProgress += 1
notifier.showRestoreProgress( notifier.showRestoreProgress(
context.stringResource(MR.strings.extensionRepo_settings), context.stringResource(MR.strings.extensionStores),
restoreProgress, restoreProgress,
restoreAmount, restoreAmount,
isSync, isSync,
@@ -8,7 +8,7 @@ data class RestoreOptions(
val libraryEntries: Boolean = true, val libraryEntries: Boolean = true,
val categories: Boolean = true, val categories: Boolean = true,
val appSettings: Boolean = true, val appSettings: Boolean = true,
val extensionRepoSettings: Boolean = true, val extensionStores: Boolean = true,
val sourceSettings: Boolean = true, val sourceSettings: Boolean = true,
) { ) {
@@ -16,11 +16,11 @@ data class RestoreOptions(
libraryEntries, libraryEntries,
categories, categories,
appSettings, appSettings,
extensionRepoSettings, extensionStores,
sourceSettings, sourceSettings,
) )
fun canRestore() = libraryEntries || categories || appSettings || extensionRepoSettings || sourceSettings fun canRestore() = libraryEntries || categories || appSettings || extensionStores || sourceSettings
companion object { companion object {
val options = persistentListOf( val options = persistentListOf(
@@ -40,9 +40,9 @@ data class RestoreOptions(
setter = { options, enabled -> options.copy(appSettings = enabled) }, setter = { options, enabled -> options.copy(appSettings = enabled) },
), ),
Entry( Entry(
label = MR.strings.extensionRepo_settings, label = MR.strings.extensionStores,
getter = RestoreOptions::extensionRepoSettings, getter = RestoreOptions::extensionStores,
setter = { options, enabled -> options.copy(extensionRepoSettings = enabled) }, setter = { options, enabled -> options.copy(extensionStores = enabled) },
), ),
Entry( Entry(
label = MR.strings.source_settings, label = MR.strings.source_settings,
@@ -55,7 +55,7 @@ data class RestoreOptions(
libraryEntries = array[0], libraryEntries = array[0],
categories = array[1], categories = array[1],
appSettings = array[2], appSettings = array[2],
extensionRepoSettings = array[3], extensionStores = array[3],
sourceSettings = array[4], sourceSettings = array[4],
) )
} }
@@ -1,38 +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.Database
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class ExtensionRepoRestorer(
private val database: Database = 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 {
database.extension_reposQueries.insert(
backupRepo.baseUrl,
backupRepo.name,
backupRepo.shortName,
backupRepo.website,
backupRepo.signingKeyFingerprint,
)
}
}
}
@@ -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,
)
}
}
@@ -204,11 +204,11 @@ class ExtensionManager(
if (extension.hasUpdate != hasUpdate) { if (extension.hasUpdate != hasUpdate) {
installedExtensionsMap[pkgName] = extension.copy( installedExtensionsMap[pkgName] = extension.copy(
hasUpdate = hasUpdate, hasUpdate = hasUpdate,
repoUrl = availableExt.repoUrl, store = availableExt.store,
) )
} else { } else {
installedExtensionsMap[pkgName] = extension.copy( installedExtensionsMap[pkgName] = extension.copy(
repoUrl = availableExt.repoUrl, store = availableExt.store,
) )
} }
changed = true changed = true
@@ -228,7 +228,7 @@ class ExtensionManager(
* @param extension The extension to be installed. * @param extension The extension to be installed.
*/ */
fun installExtension(extension: Extension.Available): Flow<InstallStep> { fun installExtension(extension: Extension.Available): Flow<InstallStep> {
return installer.downloadAndInstall(api.getApkUrl(extension), extension) return installer.downloadAndInstall(extension.apkUrl, extension)
} }
/** /**
@@ -5,64 +5,29 @@ import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult import eu.kanade.tachiyomi.extension.model.LoadResult
import eu.kanade.tachiyomi.extension.util.ExtensionLoader import eu.kanade.tachiyomi.extension.util.ExtensionLoader
import eu.kanade.tachiyomi.network.GET import mihon.domain.extension.interactor.UpdateExtensionStores
import eu.kanade.tachiyomi.network.NetworkHelper import mihon.domain.extension.repository.ExtensionStoreRepository
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs
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 tachiyomi.core.common.preference.Preference import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.time.Instant import java.time.Instant
import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.days
internal class ExtensionApi { internal class ExtensionApi {
private val networkService: NetworkHelper by injectLazy() private val repository: ExtensionStoreRepository by injectLazy()
private val preferenceStore: PreferenceStore by injectLazy() private val preferenceStore: PreferenceStore by injectLazy()
private val getExtensionRepo: GetExtensionRepo by injectLazy() private val updateExtensionStores: UpdateExtensionStores by injectLazy()
private val updateExtensionRepo: UpdateExtensionRepo by injectLazy()
private val extensionManager: ExtensionManager by injectLazy() private val extensionManager: ExtensionManager by injectLazy()
private val json: Json by injectLazy()
private val lastExtCheck: Preference<Long> by lazy { private val lastExtCheck: Preference<Long> by lazy {
preferenceStore.getLong(Preference.appStateKey("last_ext_check"), 0) preferenceStore.getLong(Preference.appStateKey("last_ext_check"), 0)
} }
suspend fun findExtensions(): List<Extension.Available> { suspend fun findExtensions(): List<Extension.Available> {
return withIOContext { return withIOContext { repository.fetchExtensions() }
getExtensionRepo.getAll()
.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)
}
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e) { "Failed to get extensions from $repoBaseUrl" }
emptyList()
}
} }
suspend fun checkForUpdates( suspend fun checkForUpdates(
@@ -76,8 +41,7 @@ internal class ExtensionApi {
return null return null
} }
// Update extension repo details updateExtensionStores()
updateExtensionRepo.awaitAll()
val extensions = if (fromAvailableExtensionList) { val extensions = if (fromAvailableExtensionList) {
extensionManager.availableExtensionsFlow.value extensionManager.availableExtensionsFlow.value
@@ -107,64 +71,4 @@ internal class ExtensionApi {
return extensionsWithUpdate return extensionsWithUpdate
} }
private fun List<ExtensionJsonObject>.toExtensions(repoUrl: String): 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,
)
}
}
fun getApkUrl(extension: Extension.Available): String {
return "${extension.repoUrl}/apk/${extension.apkName}"
}
private fun ExtensionJsonObject.extractLibVersion(): Double {
return version.substringBeforeLast('.').toDouble()
}
}
@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,
)
} }
@@ -16,7 +16,7 @@ import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.browse.ExtensionScreen import eu.kanade.presentation.browse.ExtensionScreen
import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.TabContent import eu.kanade.presentation.components.TabContent
import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsScreen import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsScreen
import eu.kanade.tachiyomi.ui.webview.WebViewScreen import eu.kanade.tachiyomi.ui.webview.WebViewScreen
@@ -45,8 +45,8 @@ fun extensionsTab(
onClick = { navigator.push(ExtensionFilterScreen()) }, onClick = { navigator.push(ExtensionFilterScreen()) },
), ),
AppBar.OverflowAction( AppBar.OverflowAction(
title = stringResource(MR.strings.label_extension_repos), title = stringResource(MR.strings.extensionStores),
onClick = { navigator.push(ExtensionReposScreen()) }, onClick = { navigator.push(ExtensionStoresScreen()) },
), ),
), ),
content = { contentPadding, _ -> content = { contentPadding, _ ->
@@ -74,7 +74,7 @@ import eu.kanade.presentation.components.AppStateBanners
import eu.kanade.presentation.components.DownloadedOnlyBannerBackgroundColor import eu.kanade.presentation.components.DownloadedOnlyBannerBackgroundColor
import eu.kanade.presentation.components.IncognitoModeBannerBackgroundColor import eu.kanade.presentation.components.IncognitoModeBannerBackgroundColor
import eu.kanade.presentation.components.IndexingBannerBackgroundColor import eu.kanade.presentation.components.IndexingBannerBackgroundColor
import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
import eu.kanade.presentation.more.settings.screen.data.RestoreBackupScreen import eu.kanade.presentation.more.settings.screen.data.RestoreBackupScreen
import eu.kanade.presentation.util.AssistContentScreen import eu.kanade.presentation.util.AssistContentScreen
import eu.kanade.presentation.util.DefaultNavigatorScreenTransition import eu.kanade.presentation.util.DefaultNavigatorScreenTransition
@@ -562,11 +562,11 @@ class MainActivity : BaseActivity() {
navigator.popUntilRoot() navigator.popUntilRoot()
navigator.push(RestoreBackupScreen(intent.data.toString())) navigator.push(RestoreBackupScreen(intent.data.toString()))
} }
// Deep link to add extension repo // Deep link to add extension store
else if (intent.scheme == "tachiyomi" && intent.data?.host == "add-repo") { else if (intent.isAddExtensionStoreIntent()) {
intent.data?.getQueryParameter("url")?.let { repoUrl -> intent.data?.getQueryParameter("url")?.let { repoUrl ->
navigator.popUntilRoot() navigator.popUntilRoot()
navigator.push(ExtensionReposScreen(repoUrl)) navigator.push(ExtensionStoresScreen(repoUrl))
} }
} }
null null
@@ -582,6 +582,11 @@ class MainActivity : BaseActivity() {
return true return true
} }
private fun Intent.isAddExtensionStoreIntent(): Boolean {
return (scheme == "tachiyomi" && data?.host == "add-repo") ||
(scheme == "mihon" && data?.host == "extension-store")
}
companion object { companion object {
const val INTENT_SEARCH = "eu.kanade.tachiyomi.SEARCH" const val INTENT_SEARCH = "eu.kanade.tachiyomi.SEARCH"
const val INTENT_SEARCH_QUERY = "query" const val INTENT_SEARCH_QUERY = "query"
@@ -4,8 +4,7 @@ import eu.kanade.domain.source.service.SourcePreferences
import logcat.LogPriority import logcat.LogPriority
import mihon.core.migration.Migration import mihon.core.migration.Migration
import mihon.core.migration.MigrationContext import mihon.core.migration.MigrationContext
import mihon.domain.extensionrepo.exception.SaveExtensionRepoException import mihon.domain.extension.repository.ExtensionStoreRepository
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat import tachiyomi.core.common.util.system.logcat
@@ -14,18 +13,14 @@ class TrustExtensionRepositoryMigration : Migration {
override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext { override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext {
val sourcePreferences = migrationContext.get<SourcePreferences>() ?: return@withIOContext false val sourcePreferences = migrationContext.get<SourcePreferences>() ?: return@withIOContext false
val extensionRepositoryRepository = val repository = migrationContext.get<ExtensionStoreRepository>() ?: return@withIOContext false
migrationContext.get<ExtensionRepoRepository>() ?: return@withIOContext false
for ((index, source) in sourcePreferences.extensionRepos.get().withIndex()) { for ((index, source) in sourcePreferences.extensionRepos.get().withIndex()) {
try { try {
extensionRepositoryRepository.upsertRepo( repository.insertFromPreference(
source, indexUrl = source.removeSuffix("/index.min.json").removeSuffix("/index.json") + "/repo.json",
"Repo #${index + 1}", name = "Repo #${index + 1}",
null,
source,
"NOFINGERPRINT-${index + 1}",
) )
} catch (e: SaveExtensionRepoException) { } catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Error Migrating Extension Repo with baseUrl: $source" } logcat(LogPriority.ERROR, e) { "Error Migrating Extension Repo with baseUrl: $source" }
} }
} }
+5 -1
View File
@@ -23,7 +23,7 @@ android {
kotlin { kotlin {
compilerOptions { compilerOptions {
freeCompilerArgs.add("-opt-in=kotlinx.serialization.ExperimentalSerializationApi") optIn.add("kotlinx.serialization.ExperimentalSerializationApi")
} }
} }
@@ -32,5 +32,9 @@ dependencies {
implementation(projects.domain) implementation(projects.domain)
implementation(projects.core.common) implementation(projects.core.common)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.serialization.jsonOkio)
implementation(libs.kotlinx.serialization.protobuf)
api(libs.bundles.sqldelight) api(libs.bundles.sqldelight)
} }
@@ -0,0 +1,7 @@
package mihon.data.extension.model
import mihon.domain.extension.model.ExtensionStore
interface BaseNetworkExtensionStore {
fun toExtensionStore(indexUrl: String): ExtensionStore
}
@@ -0,0 +1,110 @@
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,
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,
)
}
}
}
@@ -0,0 +1,61 @@
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,
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,
)
}
}
@@ -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,
)
}
}
@@ -0,0 +1,124 @@
package mihon.data.extension.repository
import app.cash.sqldelight.async.coroutines.awaitAsList
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.Database
import tachiyomi.data.subscribeToList
import tachiyomi.data.subscribeToOne
class ExtensionStoreRepositoryImpl(
private val service: ExtensionStoreService,
private val database: Database,
) : ExtensionStoreRepository {
override suspend fun insert(indexUrl: String): Result<Unit> {
return service.fetch(indexUrl).mapCatching { upsert(it) }
}
override suspend fun insertFromPreference(indexUrl: String, name: String) {
database.extension_storeQueries.upsert(
indexUrl = indexUrl,
name = name,
badgeLabel = name,
signingKey = "NO_SIGNING_KEY",
contactWebsite = indexUrl,
contactDiscord = null,
isLegacy = false,
)
}
override suspend fun refreshAll() {
try {
database.extension_storeQueries.getAll().awaitAsList().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) {
database.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(): List<Extension.Available> {
return try {
supervisorScope {
database.extension_storeQueries.getAll(::extensionStoreMapper).awaitAsList().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 database.extension_storeQueries.getAll(::extensionStoreMapper).awaitAsList()
}
override fun getAllAsFlow(): Flow<List<ExtensionStore>> {
return database.extension_storeQueries.getAll(::extensionStoreMapper).subscribeToList()
}
override fun getCountAsFlow(): Flow<Long> {
return database.extension_storeQueries
.getCount()
.subscribeToOne()
}
override suspend fun remove(indexUrl: String) {
database.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,
)
}
@@ -0,0 +1,97 @@
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())
} catch (e: IllegalArgumentException) {
logcat(LogPriority.ERROR, e) {
"Failed to add extension store '$updatedIndexUrl'"
}
try {
json.decodeFromBufferedSource<NetworkExtensionStore>(source.peek())
} catch (e: IllegalArgumentException) {
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().body.source().use {
json.decodeFromBufferedSource<NetworkLegacyExtensionRepo>(it)
}
}
}
}
.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) {
val response = network.client.newCall(GET(store.indexUrl)).awaitSuccess()
response.body.source().use { source ->
try {
protoBuf.decodeFromByteArray<NetworkExtensionStore>(source.peek().readByteArray())
.toAvailableExtensions(store)
} catch (_: IllegalArgumentException) {
json.decodeFromBufferedSource<NetworkExtensionStore>(source.peek())
.toAvailableExtensions(store)
}
}
} else {
val storeBaseUrl = store.indexUrl.removeSuffix("/repo.json")
val response = network.client.newCall(GET("$storeBaseUrl/index.min.json")).awaitSuccess()
response.body.source().use { 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)
}
}
}
@@ -1,116 +0,0 @@
package mihon.data.repository
import android.database.SQLException
import app.cash.sqldelight.async.coroutines.awaitAsList
import app.cash.sqldelight.async.coroutines.awaitAsOneOrNull
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.Database
import tachiyomi.data.subscribeToList
import tachiyomi.data.subscribeToOne
class ExtensionRepoRepositoryImpl(
private val database: Database,
) : ExtensionRepoRepository {
override fun subscribeAll(): Flow<List<ExtensionRepo>> {
return database.extension_reposQueries
.findAll(::mapExtensionRepo)
.subscribeToList()
}
override suspend fun getAll(): List<ExtensionRepo> {
return database.extension_reposQueries
.findAll(::mapExtensionRepo)
.awaitAsList()
}
override suspend fun getRepo(baseUrl: String): ExtensionRepo? {
return database.extension_reposQueries
.findOne(baseUrl, ::mapExtensionRepo)
.awaitAsOneOrNull()
}
override suspend fun getRepoBySigningKeyFingerprint(fingerprint: String): ExtensionRepo? {
return database.extension_reposQueries
.findOneBySigningKeyFingerprint(fingerprint, ::mapExtensionRepo)
.awaitAsOneOrNull()
}
override fun getCount(): Flow<Int> {
return database.extension_reposQueries
.count()
.subscribeToOne()
.map { it.toInt() }
}
override suspend fun insertRepo(
baseUrl: String,
name: String,
shortName: String?,
website: String,
signingKeyFingerprint: String,
) {
try {
database.extension_reposQueries.insert(
baseUrl,
name,
shortName,
website,
signingKeyFingerprint,
)
} catch (ex: SQLException) {
throw SaveExtensionRepoException(ex)
}
}
override suspend fun upsertRepo(
baseUrl: String,
name: String,
shortName: String?,
website: String,
signingKeyFingerprint: String,
) {
try {
database.extension_reposQueries.upsert(
baseUrl,
name,
shortName,
website,
signingKeyFingerprint,
)
} catch (ex: SQLException) {
throw SaveExtensionRepoException(ex)
}
}
override suspend fun replaceRepo(newRepo: ExtensionRepo) {
database.extension_reposQueries.replace(
newRepo.baseUrl,
newRepo.name,
newRepo.shortName,
newRepo.website,
newRepo.signingKeyFingerprint,
)
}
override suspend fun deleteRepo(baseUrl: String) {
database.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,
)
}
@@ -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;
@@ -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;
@@ -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;
@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.extension.model
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.Source
import mihon.domain.extension.model.ExtensionStore
import tachiyomi.domain.source.model.StubSource import tachiyomi.domain.source.model.StubSource
sealed class Extension { sealed class Extension {
@@ -28,7 +29,7 @@ sealed class Extension {
val hasUpdate: Boolean = false, val hasUpdate: Boolean = false,
val isObsolete: Boolean = false, val isObsolete: Boolean = false,
val isShared: Boolean, val isShared: Boolean,
val repoUrl: String? = null, val store: ExtensionStore? = null,
) : Extension() ) : Extension()
data class Available( data class Available(
@@ -40,9 +41,9 @@ sealed class Extension {
override val lang: String, override val lang: String,
override val isNsfw: Boolean, override val isNsfw: Boolean,
val sources: List<Source>, val sources: List<Source>,
val apkName: String, val apkUrl: String,
val iconUrl: String, val iconUrl: String,
val repoUrl: String, val store: ExtensionStore,
) : Extension() { ) : Extension() {
data class Source( data class Source(
@@ -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)
}
}
@@ -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()
}
@@ -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()
}
@@ -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)
}
}
@@ -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()
}
}
@@ -0,0 +1,15 @@
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?,
)
}
@@ -0,0 +1,23 @@
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(): List<Extension.Available>
suspend fun getAll(): List<ExtensionStore>
fun getAllAsFlow(): Flow<List<ExtensionStore>>
fun getCountAsFlow(): Flow<Long>
suspend fun remove(indexUrl: String)
}
@@ -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)
@@ -1,72 +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
}
}
@@ -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)
}
}
@@ -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()
}
@@ -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()
}
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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,
)
@@ -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)
}
@@ -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,
)
}
@@ -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
}
}
}
}
@@ -363,20 +363,19 @@
<string name="ext_revoke_trust">Revoke trusted unknown extensions</string> <string name="ext_revoke_trust">Revoke trusted unknown extensions</string>
<!-- Extension repos --> <!-- Extension repos -->
<string name="label_extension_repos">Extension repos</string>
<string name="information_empty_repos">You have no repos set.</string>
<string name="action_add_repo">Add repo</string>
<string name="label_add_repo_input">Repo URL</string>
<string name="action_add_repo_message">Add additional repos to %s. This should be a URL that ends with \"index.min.json\".</string>
<string name="error_repo_exists">This repo already exists!</string>
<string name="action_delete_repo">Delete repo</string>
<string name="invalid_repo_name">Invalid repo URL</string>
<string name="delete_repo_confirmation">Do you wish to delete the repo \"%s\"?</string>
<string name="add_repo_confirmation">Do you wish to add the repo \"%s\"?</string>
<string name="action_open_repo">Open source repo</string> <string name="action_open_repo">Open source repo</string>
<string name="action_replace_repo">Replace</string>
<string name="action_replace_repo_title">Signing Key Fingerprint Already Exists</string> <!-- Extension store -->
<string name="action_replace_repo_message">Repository %1$s has the same Signing Key Fingerprint as %2$s.\nIf this is expected, %2$s will be replaced, otherwise contact your repo maintainer.</string> <string name="extensionStores">Extension stores</string>
<string name="action.addExtensionStore">Add extension store</string>
<string name="extensionStoresScreen.emptyLabel">You haven\'t added an extension store yet</string>
<string name="extensionStoresScreen.addStore.title">Add extension store</string>
<string name="extensionStoresScreen.addStore.alreadyExists">This extension store already exists</string>
<string name="extensionStoresScreen.addStore.processing">Processing…</string>
<string name="extensionStoresScreen.addStoreInput.inputLabel">Store index URL</string>
<string name="extensionStoresScreen.addStoreDeeplink.bodyText">Do you wish to add the extension store below?</string>
<string name="extensionStoresScreen.deleteStore.title">Remove extension store</string>
<string name="extensionStoresScreen.deleteStore.body">Do you wish to remove the extension store \"%1$s\" (%2$s)?</string>
<!-- Reader section --> <!-- Reader section -->
<string name="pref_fullscreen">Fullscreen</string> <string name="pref_fullscreen">Fullscreen</string>
@@ -566,7 +565,6 @@
<string name="backup_choice">What do you want to backup?</string> <string name="backup_choice">What do you want to backup?</string>
<string name="app_settings">App settings</string> <string name="app_settings">App settings</string>
<string name="source_settings">Source settings</string> <string name="source_settings">Source settings</string>
<string name="extensionRepo_settings">Extension repos</string>
<string name="private_settings">Include sensitive settings (e.g., tracker login tokens)</string> <string name="private_settings">Include sensitive settings (e.g., tracker login tokens)</string>
<string name="non_library_settings">All read entries</string> <string name="non_library_settings">All read entries</string>
<string name="creating_backup">Creating backup</string> <string name="creating_backup">Creating backup</string>