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.
## [Unreleased]
### Added
- Add support for `tachiyomix` extension index format ([@AntsyLich](https://github.com/AntsyLich)) ([#3349](https://github.com/mihonapp/mihon/pull/3349))
### Changed
- Change all reference of extension repo to extension store ([@AntsyLich](https://github.com/AntsyLich)) ([#3349](https://github.com/mihonapp/mihon/pull/3349))
### Fixed
- Add missing `outlineVariant` color to Nord theme ([@CompileConnected](https://github.com/CompileConnected)) ([#3184](https://github.com/mihonapp/mihon/pull/3184))
+13 -2
View File
@@ -60,8 +60,8 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Deep link to add repos -->
<intent-filter android:label="@string/action_add_repo">
<!-- Legacy deeplink to add extension store -->
<intent-filter android:label="@string/action_addExtensionStore">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
@@ -71,6 +71,17 @@
<data android:host="add-repo" />
</intent-filter>
<!-- Deeplink to add extension store -->
<intent-filter android:label="@string/action_addExtensionStore">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="mihon" />
<data android:host="extension-store" />
</intent-filter>
<!-- Open backup files -->
<intent-filter android:label="@string/pref_restore_backup">
<action android:name="android.intent.action.VIEW" />
@@ -25,16 +25,15 @@ import eu.kanade.domain.track.interactor.AddTracks
import eu.kanade.domain.track.interactor.RefreshTracks
import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack
import eu.kanade.domain.track.interactor.TrackChapter
import mihon.data.repository.ExtensionRepoRepositoryImpl
import mihon.data.extension.repository.ExtensionStoreRepositoryImpl
import mihon.data.extension.service.ExtensionStoreService
import mihon.domain.chapter.interactor.FilterChaptersForDownload
import mihon.domain.extensionrepo.interactor.CreateExtensionRepo
import mihon.domain.extensionrepo.interactor.DeleteExtensionRepo
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount
import mihon.domain.extensionrepo.interactor.ReplaceExtensionRepo
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import mihon.domain.extensionrepo.service.ExtensionRepoService
import mihon.domain.extension.interactor.AddExtensionStore
import mihon.domain.extension.interactor.GetExtensionStoreCountAsFlow
import mihon.domain.extension.interactor.GetExtensionStores
import mihon.domain.extension.interactor.RemoveExtensionStore
import mihon.domain.extension.interactor.UpdateExtensionStores
import mihon.domain.extension.repository.ExtensionStoreRepository
import mihon.domain.migration.usecases.MigrateMangaUseCase
import mihon.domain.upcoming.interactor.GetUpcomingManga
import tachiyomi.data.category.CategoryRepositoryImpl
@@ -195,14 +194,14 @@ class DomainModule : InjektModule {
addFactory { ToggleSourcePin(get()) }
addFactory { TrustExtension(get(), get()) }
addSingletonFactory<ExtensionRepoRepository> { ExtensionRepoRepositoryImpl(get()) }
addFactory { ExtensionRepoService(get(), get()) }
addFactory { GetExtensionRepo(get()) }
addFactory { GetExtensionRepoCount(get()) }
addFactory { CreateExtensionRepo(get(), get()) }
addFactory { DeleteExtensionRepo(get()) }
addFactory { ReplaceExtensionRepo(get()) }
addFactory { UpdateExtensionRepo(get(), get()) }
addSingletonFactory { ExtensionStoreService(get(), get(), get()) }
addSingletonFactory<ExtensionStoreRepository> { ExtensionStoreRepositoryImpl(get(), get()) }
addFactory { AddExtensionStore(get()) }
addFactory { GetExtensionStoreCountAsFlow(get()) }
addFactory { GetExtensionStores(get()) }
addFactory { RemoveExtensionStore(get()) }
addFactory { UpdateExtensionStores(get()) }
addFactory { ToggleIncognito(get()) }
addFactory { GetIncognitoState(get(), get(), get()) }
}
@@ -17,11 +17,7 @@ class GetExtensionLanguages(
) { enabledLanguage, availableExtensions ->
availableExtensions
.flatMap { ext ->
if (ext.sources.isEmpty()) {
listOf(ext.lang)
} else {
ext.sources.map { it.lang }
}
ext.sources.map { it.lang }
}
.distinct()
.sortedWith(
@@ -39,9 +39,6 @@ class GetExtensionsByType(
(showNsfwSources || !extension.isNsfw)
}
.flatMap { ext ->
if (ext.sources.isEmpty()) {
return@flatMap if (ext.lang in enabledLanguages) listOf(ext) else emptyList()
}
ext.sources.filter { it.lang in enabledLanguages }
.map {
ext.copy(
@@ -3,16 +3,16 @@ package eu.kanade.domain.extension.interactor
import android.content.pm.PackageInfo
import androidx.core.content.pm.PackageInfoCompat
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
class TrustExtension(
private val extensionRepoRepository: ExtensionRepoRepository,
private val repository: ExtensionStoreRepository,
private val preferences: SourcePreferences,
) {
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()}"
return trustedFingerprints.any { fingerprints.contains(it) } || key in preferences.trustedExtensions.get()
}
@@ -80,12 +80,12 @@ fun ExtensionDetailsScreen(
val uriHandler = LocalUriHandler.current
val url = remember(state.extension) {
val regex = """https://raw.githubusercontent.com/(.+?)/(.+?)/.+""".toRegex()
regex.find(state.extension?.repoUrl.orEmpty())
regex.find(state.extension?.store?.indexUrl.orEmpty())
?.let {
val (user, repo) = it.destructured
"https://github.com/$user/$repo"
}
?: state.extension?.repoUrl
?: state.extension?.store?.indexUrl
}
Scaffold(
@@ -248,14 +248,17 @@ private fun DetailsHeader(
if (extension is Extension.Installed) {
append("\n\n")
append(
appendLine(
"""
Update available: ${extension.hasUpdate}
Obsolete: ${extension.isObsolete}
Shared: ${extension.isShared}
Repository: ${extension.repoUrl}
""".trimIndent(),
)
val store = extension.store
if (store != null) {
append("Repository: ${store.indexUrl}")
}
}
}
context.copyToClipboard("Extension Debug information", extDebugInfo)
@@ -47,7 +47,7 @@ import eu.kanade.presentation.browse.components.BaseBrowseItem
import eu.kanade.presentation.browse.components.ExtensionIcon
import eu.kanade.presentation.components.WarningBanner
import eu.kanade.presentation.manga.components.DotSeparatorNoSpaceText
import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen
import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
import eu.kanade.presentation.util.animateItemFastScroll
import eu.kanade.presentation.util.rememberRequestPackageInstallsPermissionState
import eu.kanade.tachiyomi.extension.model.Extension
@@ -106,9 +106,9 @@ fun ExtensionScreen(
modifier = Modifier.padding(contentPadding),
actions = persistentListOf(
EmptyScreenAction(
stringRes = MR.strings.label_extension_repos,
stringRes = MR.strings.extensionStores,
icon = Icons.Outlined.Settings,
onClick = { navigator.push(ExtensionReposScreen()) },
onClick = { navigator.push(ExtensionStoresScreen()) },
),
),
)
@@ -11,10 +11,10 @@ import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.domain.source.service.SourcePreferences
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 kotlinx.collections.immutable.persistentListOf
import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount
import mihon.domain.extension.interactor.GetExtensionStoreCountAsFlow
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.pluralStringResource
@@ -34,9 +34,9 @@ object SettingsBrowseScreen : SearchableSettings {
val navigator = LocalNavigator.currentOrThrow
val sourcePreferences = remember { Injekt.get<SourcePreferences>() }
val getExtensionRepoCount = remember { Injekt.get<GetExtensionRepoCount>() }
val getExtensionStoreCountAsFlow = remember { Injekt.get<GetExtensionStoreCountAsFlow>() }
val reposCount by getExtensionRepoCount.subscribe().collectAsState(0)
val reposCount by getExtensionStoreCountAsFlow().collectAsState(0)
return listOf(
Preference.PreferenceGroup(
@@ -47,10 +47,10 @@ object SettingsBrowseScreen : SearchableSettings {
title = stringResource(MR.strings.pref_hide_in_library_items),
),
Preference.PreferenceItem.TextPreference(
title = stringResource(MR.strings.label_extension_repos),
subtitle = pluralStringResource(MR.plurals.num_repos, reposCount, reposCount),
title = stringResource(MR.strings.extensionStores),
subtitle = pluralStringResource(MR.plurals.num_repos, reposCount.toInt(), reposCount),
onClick = {
navigator.push(ExtensionReposScreen())
navigator.push(ExtensionStoresScreen())
},
),
),
@@ -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.material.icons.Icons
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.Delete
import androidx.compose.material.icons.outlined.Public
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -20,21 +20,22 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import eu.kanade.tachiyomi.util.system.copyToClipboard
import kotlinx.collections.immutable.ImmutableSet
import mihon.domain.extensionrepo.model.ExtensionRepo
import mihon.domain.extension.model.ExtensionStore
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.icons.CustomIcons
import tachiyomi.presentation.core.icons.Discord
@Composable
fun ExtensionReposContent(
repos: ImmutableSet<ExtensionRepo>,
fun ExtensionStoresContent(
repos: List<ExtensionStore>,
lazyListState: LazyListState,
paddingValues: PaddingValues,
onOpenWebsite: (ExtensionRepo) -> Unit,
onClickDelete: (String) -> Unit,
onCopy: (ExtensionStore) -> Unit,
onOpenWebsite: (ExtensionStore) -> Unit,
onOpenDiscord: (ExtensionStore) -> Unit,
onClickDelete: (ExtensionStore) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
@@ -45,11 +46,13 @@ fun ExtensionReposContent(
) {
repos.forEach {
item {
ExtensionRepoListItem(
ExtensionStoresListItem(
modifier = Modifier.animateItem(),
repo = it,
store = it,
onOpenWebsite = { onOpenWebsite(it) },
onDelete = { onClickDelete(it.baseUrl) },
onOpenDiscord = { onOpenDiscord(it) },
onCopy = { onCopy(it) },
onDelete = { onClickDelete(it) },
)
}
}
@@ -57,14 +60,14 @@ fun ExtensionReposContent(
}
@Composable
private fun ExtensionRepoListItem(
repo: ExtensionRepo,
private fun ExtensionStoresListItem(
store: ExtensionStore,
onOpenWebsite: () -> Unit,
onOpenDiscord: () -> Unit,
onCopy: () -> Unit,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
ElevatedCard(
modifier = modifier,
) {
@@ -80,7 +83,7 @@ private fun ExtensionRepoListItem(
) {
Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null)
Text(
text = repo.name,
text = store.name,
modifier = Modifier.padding(start = MaterialTheme.padding.medium),
style = MaterialTheme.typography.titleMedium,
)
@@ -92,17 +95,21 @@ private fun ExtensionRepoListItem(
) {
IconButton(onClick = onOpenWebsite) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.OpenInNew,
imageVector = Icons.Outlined.Public,
contentDescription = stringResource(MR.strings.action_open_in_browser),
)
}
IconButton(
onClick = {
val url = "${repo.baseUrl}/index.min.json"
context.copyToClipboard(url, url)
},
) {
if (store.contact.discord != null) {
IconButton(onClick = onOpenDiscord) {
Icon(
imageVector = CustomIcons.Discord,
contentDescription = null,
)
}
}
IconButton(onClick = onCopy) {
Icon(
imageVector = Icons.Outlined.ContentCopy,
contentDescription = stringResource(MR.strings.action_copy_to_clipboard),
@@ -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
import androidx.compose.foundation.layout.PaddingValues
@@ -14,8 +12,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import eu.kanade.presentation.category.components.CategoryFloatingActionButton
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.more.settings.screen.browse.RepoScreenState
import mihon.domain.extensionrepo.model.ExtensionRepo
import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoreScreenState
import mihon.domain.extension.model.ExtensionStore
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Scaffold
import tachiyomi.presentation.core.components.material.padding
@@ -25,11 +23,13 @@ import tachiyomi.presentation.core.screens.EmptyScreen
import tachiyomi.presentation.core.util.plus
@Composable
fun ExtensionReposScreen(
state: RepoScreenState.Success,
fun ExtensionStoresScreen(
state: ExtensionStoreScreenState.Success,
onClickCreate: () -> Unit,
onOpenWebsite: (ExtensionRepo) -> Unit,
onClickDelete: (String) -> Unit,
onCopy: (ExtensionStore) -> Unit,
onOpenWebsite: (ExtensionStore) -> Unit,
onOpenDiscord: (ExtensionStore) -> Unit,
onClickDelete: (ExtensionStore) -> Unit,
onClickRefresh: () -> Unit,
navigateUp: () -> Unit,
) {
@@ -38,7 +38,7 @@ fun ExtensionReposScreen(
topBar = { scrollBehavior ->
AppBar(
navigateUp = navigateUp,
title = stringResource(MR.strings.label_extension_repos),
title = stringResource(MR.strings.extensionStores),
scrollBehavior = scrollBehavior,
actions = {
IconButton(onClick = onClickRefresh) {
@@ -59,18 +59,20 @@ fun ExtensionReposScreen(
) { paddingValues ->
if (state.isEmpty) {
EmptyScreen(
MR.strings.information_empty_repos,
MR.strings.extensionStoresScreen_emptyLabel,
modifier = Modifier.padding(paddingValues),
)
return@Scaffold
}
ExtensionReposContent(
repos = state.repos,
ExtensionStoresContent(
repos = state.stores,
lazyListState = lazyListState,
paddingValues = paddingValues + topSmallPaddingValues +
PaddingValues(horizontal = MaterialTheme.padding.medium),
onCopy = onCopy,
onOpenWebsite = onOpenWebsite,
onOpenDiscord = onOpenDiscord,
onClickDelete = onClickDelete,
)
}
@@ -6,13 +6,13 @@ import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.backup.BackupFileValidator
import eu.kanade.tachiyomi.data.backup.create.creators.CategoriesBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.ExtensionRepoBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.ExtensionStoresBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.MangaBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.PreferenceBackupCreator
import eu.kanade.tachiyomi.data.backup.create.creators.SourcesBackupCreator
import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionRepos
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSource
@@ -49,7 +49,7 @@ class BackupCreator(
private val categoriesBackupCreator: CategoriesBackupCreator = CategoriesBackupCreator(),
private val mangaBackupCreator: MangaBackupCreator = MangaBackupCreator(),
private val preferenceBackupCreator: PreferenceBackupCreator = PreferenceBackupCreator(),
private val extensionRepoBackupCreator: ExtensionRepoBackupCreator = ExtensionRepoBackupCreator(),
private val extensionStoresBackupCreator: ExtensionStoresBackupCreator = ExtensionStoresBackupCreator(),
private val sourcesBackupCreator: SourcesBackupCreator = SourcesBackupCreator(),
) {
@@ -85,7 +85,7 @@ class BackupCreator(
backupCategories = backupCategories(options),
backupSources = backupSources(backupManga),
backupPreferences = backupAppPreferences(options),
backupExtensionRepo = backupExtensionRepos(options),
backupExtensionStores = backupExtensionStores(options),
backupSourcePreferences = backupSourcePreferences(options),
)
@@ -141,10 +141,10 @@ class BackupCreator(
return preferenceBackupCreator.createApp(includePrivatePreferences = options.privateSettings)
}
private suspend fun backupExtensionRepos(options: BackupOptions): List<BackupExtensionRepos> {
if (!options.extensionRepoSettings) return emptyList()
private suspend fun backupExtensionStores(options: BackupOptions): List<BackupExtensionStore> {
if (!options.extensionStores) return emptyList()
return extensionRepoBackupCreator()
return extensionStoresBackupCreator()
}
private fun backupSourcePreferences(options: BackupOptions): List<BackupSourcePreferences> {
@@ -12,7 +12,7 @@ data class BackupOptions(
val history: Boolean = true,
val readEntries: Boolean = true,
val appSettings: Boolean = true,
val extensionRepoSettings: Boolean = true,
val extensionStores: Boolean = true,
val sourceSettings: Boolean = true,
val privateSettings: Boolean = false,
) {
@@ -25,12 +25,12 @@ data class BackupOptions(
history,
readEntries,
appSettings,
extensionRepoSettings,
extensionStores,
sourceSettings,
privateSettings,
)
fun canCreate() = libraryEntries || categories || appSettings || extensionRepoSettings || sourceSettings
fun canCreate() = libraryEntries || categories || appSettings || extensionStores || sourceSettings
companion object {
val libraryOptions = persistentListOf(
@@ -77,9 +77,9 @@ data class BackupOptions(
setter = { options, enabled -> options.copy(appSettings = enabled) },
),
Entry(
label = MR.strings.extensionRepo_settings,
getter = BackupOptions::extensionRepoSettings,
setter = { options, enabled -> options.copy(extensionRepoSettings = enabled) },
label = MR.strings.extensionStores,
getter = BackupOptions::extensionStores,
setter = { options, enabled -> options.copy(extensionStores = enabled) },
),
Entry(
label = MR.strings.source_settings,
@@ -102,7 +102,7 @@ data class BackupOptions(
history = array[4],
readEntries = array[5],
appSettings = array[6],
extensionRepoSettings = array[7],
extensionStores = array[7],
sourceSettings = array[8],
privateSettings = array[9],
)
@@ -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(104) var backupPreferences: List<BackupPreference> = emptyList(),
@ProtoNumber(105) var backupSourcePreferences: List<BackupSourcePreferences> = emptyList(),
@ProtoNumber(106) var backupExtensionRepo: List<BackupExtensionRepos> = emptyList(),
@ProtoNumber(106) var backupExtensionStores: List<BackupExtensionStore> = emptyList(),
)
@@ -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.BackupNotifier
import eu.kanade.tachiyomi.data.backup.models.BackupCategory
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionRepos
import eu.kanade.tachiyomi.data.backup.models.BackupExtensionStore
import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupPreference
import eu.kanade.tachiyomi.data.backup.models.BackupSourcePreferences
import eu.kanade.tachiyomi.data.backup.restore.restorers.CategoriesRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.ExtensionRepoRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.ExtensionStoreRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.MangaRestorer
import eu.kanade.tachiyomi.data.backup.restore.restorers.PreferenceRestorer
import eu.kanade.tachiyomi.util.system.createFileInCacheDir
@@ -32,7 +32,7 @@ class BackupRestorer(
private val categoriesRestorer: CategoriesRestorer = CategoriesRestorer(),
private val preferenceRestorer: PreferenceRestorer = PreferenceRestorer(context),
private val extensionRepoRestorer: ExtensionRepoRestorer = ExtensionRepoRestorer(),
private val extensionStoreRestorer: ExtensionStoreRestorer = ExtensionStoreRestorer(),
private val mangaRestorer: MangaRestorer = MangaRestorer(),
) {
@@ -79,8 +79,8 @@ class BackupRestorer(
if (options.appSettings) {
restoreAmount += 1
}
if (options.extensionRepoSettings) {
restoreAmount += backup.backupExtensionRepo.size
if (options.extensionStores) {
restoreAmount += backup.backupExtensionStores.size
}
if (options.sourceSettings) {
restoreAmount += 1
@@ -99,8 +99,8 @@ class BackupRestorer(
if (options.libraryEntries) {
restoreManga(backup.backupManga, if (options.categories) backup.backupCategories else emptyList())
}
if (options.extensionRepoSettings) {
restoreExtensionRepos(backup.backupExtensionRepo)
if (options.extensionStores) {
restoreExtensionStores(backup.backupExtensionStores)
}
// TODO: optionally trigger online library + tracker update
@@ -172,22 +172,22 @@ class BackupRestorer(
)
}
private fun CoroutineScope.restoreExtensionRepos(
backupExtensionRepo: List<BackupExtensionRepos>,
private fun CoroutineScope.restoreExtensionStores(
backupExtensionStores: List<BackupExtensionStore>,
) = launch {
backupExtensionRepo
backupExtensionStores
.forEach {
ensureActive()
try {
extensionRepoRestorer(it)
extensionStoreRestorer(it)
} catch (e: Exception) {
errors.add(Date() to "Error Adding Repo: ${it.name} : ${e.message}")
}
restoreProgress += 1
notifier.showRestoreProgress(
context.stringResource(MR.strings.extensionRepo_settings),
context.stringResource(MR.strings.extensionStores),
restoreProgress,
restoreAmount,
isSync,
@@ -8,7 +8,7 @@ data class RestoreOptions(
val libraryEntries: Boolean = true,
val categories: Boolean = true,
val appSettings: Boolean = true,
val extensionRepoSettings: Boolean = true,
val extensionStores: Boolean = true,
val sourceSettings: Boolean = true,
) {
@@ -16,11 +16,11 @@ data class RestoreOptions(
libraryEntries,
categories,
appSettings,
extensionRepoSettings,
extensionStores,
sourceSettings,
)
fun canRestore() = libraryEntries || categories || appSettings || extensionRepoSettings || sourceSettings
fun canRestore() = libraryEntries || categories || appSettings || extensionStores || sourceSettings
companion object {
val options = persistentListOf(
@@ -40,9 +40,9 @@ data class RestoreOptions(
setter = { options, enabled -> options.copy(appSettings = enabled) },
),
Entry(
label = MR.strings.extensionRepo_settings,
getter = RestoreOptions::extensionRepoSettings,
setter = { options, enabled -> options.copy(extensionRepoSettings = enabled) },
label = MR.strings.extensionStores,
getter = RestoreOptions::extensionStores,
setter = { options, enabled -> options.copy(extensionStores = enabled) },
),
Entry(
label = MR.strings.source_settings,
@@ -55,7 +55,7 @@ data class RestoreOptions(
libraryEntries = array[0],
categories = array[1],
appSettings = array[2],
extensionRepoSettings = array[3],
extensionStores = array[3],
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) {
installedExtensionsMap[pkgName] = extension.copy(
hasUpdate = hasUpdate,
repoUrl = availableExt.repoUrl,
store = availableExt.store,
)
} else {
installedExtensionsMap[pkgName] = extension.copy(
repoUrl = availableExt.repoUrl,
store = availableExt.store,
)
}
changed = true
@@ -228,7 +228,7 @@ class ExtensionManager(
* @param extension The extension to be installed.
*/
fun installExtension(extension: Extension.Available): Flow<InstallStep> {
return installer.downloadAndInstall(api.getApkUrl(extension), extension)
return installer.downloadAndInstall(extension.apkUrl, extension)
}
/**
@@ -5,64 +5,29 @@ import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult
import eu.kanade.tachiyomi.extension.util.ExtensionLoader
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import logcat.LogPriority
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
import mihon.domain.extensionrepo.model.ExtensionRepo
import mihon.domain.extension.interactor.UpdateExtensionStores
import mihon.domain.extension.repository.ExtensionStoreRepository
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat
import uy.kohesive.injekt.injectLazy
import java.time.Instant
import kotlin.time.Duration.Companion.days
internal class ExtensionApi {
private val networkService: NetworkHelper by injectLazy()
private val repository: ExtensionStoreRepository by injectLazy()
private val preferenceStore: PreferenceStore by injectLazy()
private val getExtensionRepo: GetExtensionRepo by injectLazy()
private val updateExtensionRepo: UpdateExtensionRepo by injectLazy()
private val updateExtensionStores: UpdateExtensionStores by injectLazy()
private val extensionManager: ExtensionManager by injectLazy()
private val json: Json by injectLazy()
private val lastExtCheck: Preference<Long> by lazy {
preferenceStore.getLong(Preference.appStateKey("last_ext_check"), 0)
}
suspend fun findExtensions(): List<Extension.Available> {
return withIOContext {
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()
}
return withIOContext { repository.fetchExtensions() }
}
suspend fun checkForUpdates(
@@ -76,8 +41,7 @@ internal class ExtensionApi {
return null
}
// Update extension repo details
updateExtensionRepo.awaitAll()
updateExtensionStores()
val extensions = if (fromAvailableExtensionList) {
extensionManager.availableExtensionsFlow.value
@@ -107,64 +71,4 @@ internal class ExtensionApi {
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.components.AppBar
import eu.kanade.presentation.components.TabContent
import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen
import eu.kanade.presentation.more.settings.screen.browse.ExtensionStoresScreen
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.ui.browse.extension.details.ExtensionDetailsScreen
import eu.kanade.tachiyomi.ui.webview.WebViewScreen
@@ -45,8 +45,8 @@ fun extensionsTab(
onClick = { navigator.push(ExtensionFilterScreen()) },
),
AppBar.OverflowAction(
title = stringResource(MR.strings.label_extension_repos),
onClick = { navigator.push(ExtensionReposScreen()) },
title = stringResource(MR.strings.extensionStores),
onClick = { navigator.push(ExtensionStoresScreen()) },
),
),
content = { contentPadding, _ ->
@@ -74,7 +74,7 @@ import eu.kanade.presentation.components.AppStateBanners
import eu.kanade.presentation.components.DownloadedOnlyBannerBackgroundColor
import eu.kanade.presentation.components.IncognitoModeBannerBackgroundColor
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.util.AssistContentScreen
import eu.kanade.presentation.util.DefaultNavigatorScreenTransition
@@ -562,11 +562,11 @@ class MainActivity : BaseActivity() {
navigator.popUntilRoot()
navigator.push(RestoreBackupScreen(intent.data.toString()))
}
// Deep link to add extension repo
else if (intent.scheme == "tachiyomi" && intent.data?.host == "add-repo") {
// Deep link to add extension store
else if (intent.isAddExtensionStoreIntent()) {
intent.data?.getQueryParameter("url")?.let { repoUrl ->
navigator.popUntilRoot()
navigator.push(ExtensionReposScreen(repoUrl))
navigator.push(ExtensionStoresScreen(repoUrl))
}
}
null
@@ -582,6 +582,11 @@ class MainActivity : BaseActivity() {
return true
}
private fun Intent.isAddExtensionStoreIntent(): Boolean {
return (scheme == "tachiyomi" && data?.host == "add-repo") ||
(scheme == "mihon" && data?.host == "extension-store")
}
companion object {
const val INTENT_SEARCH = "eu.kanade.tachiyomi.SEARCH"
const val INTENT_SEARCH_QUERY = "query"
@@ -4,8 +4,7 @@ import eu.kanade.domain.source.service.SourcePreferences
import logcat.LogPriority
import mihon.core.migration.Migration
import mihon.core.migration.MigrationContext
import mihon.domain.extensionrepo.exception.SaveExtensionRepoException
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
import mihon.domain.extension.repository.ExtensionStoreRepository
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.system.logcat
@@ -14,18 +13,14 @@ class TrustExtensionRepositoryMigration : Migration {
override suspend fun invoke(migrationContext: MigrationContext): Boolean = withIOContext {
val sourcePreferences = migrationContext.get<SourcePreferences>() ?: return@withIOContext false
val extensionRepositoryRepository =
migrationContext.get<ExtensionRepoRepository>() ?: return@withIOContext false
val repository = migrationContext.get<ExtensionStoreRepository>() ?: return@withIOContext false
for ((index, source) in sourcePreferences.extensionRepos.get().withIndex()) {
try {
extensionRepositoryRepository.upsertRepo(
source,
"Repo #${index + 1}",
null,
source,
"NOFINGERPRINT-${index + 1}",
repository.insertFromPreference(
indexUrl = source.removeSuffix("/index.min.json").removeSuffix("/index.json") + "/repo.json",
name = "Repo #${index + 1}",
)
} catch (e: SaveExtensionRepoException) {
} catch (e: Exception) {
logcat(LogPriority.ERROR, e) { "Error Migrating Extension Repo with baseUrl: $source" }
}
}
+5 -1
View File
@@ -23,7 +23,7 @@ android {
kotlin {
compilerOptions {
freeCompilerArgs.add("-opt-in=kotlinx.serialization.ExperimentalSerializationApi")
optIn.add("kotlinx.serialization.ExperimentalSerializationApi")
}
}
@@ -32,5 +32,9 @@ dependencies {
implementation(projects.domain)
implementation(projects.core.common)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.serialization.jsonOkio)
implementation(libs.kotlinx.serialization.protobuf)
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 eu.kanade.tachiyomi.source.Source
import mihon.domain.extension.model.ExtensionStore
import tachiyomi.domain.source.model.StubSource
sealed class Extension {
@@ -28,7 +29,7 @@ sealed class Extension {
val hasUpdate: Boolean = false,
val isObsolete: Boolean = false,
val isShared: Boolean,
val repoUrl: String? = null,
val store: ExtensionStore? = null,
) : Extension()
data class Available(
@@ -40,9 +41,9 @@ sealed class Extension {
override val lang: String,
override val isNsfw: Boolean,
val sources: List<Source>,
val apkName: String,
val apkUrl: String,
val iconUrl: String,
val repoUrl: String,
val store: ExtensionStore,
) : Extension() {
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>
<!-- Extension repos -->
<string name="label_extension_repos">Extension repos</string>
<string name="information_empty_repos">You have no repos set.</string>
<string name="action_add_repo">Add repo</string>
<string name="label_add_repo_input">Repo URL</string>
<string name="action_add_repo_message">Add additional repos to %s. This should be a URL that ends with \"index.min.json\".</string>
<string name="error_repo_exists">This repo already exists!</string>
<string name="action_delete_repo">Delete repo</string>
<string name="invalid_repo_name">Invalid repo URL</string>
<string name="delete_repo_confirmation">Do you wish to delete the repo \"%s\"?</string>
<string name="add_repo_confirmation">Do you wish to add the repo \"%s\"?</string>
<string name="action_open_repo">Open source repo</string>
<string name="action_replace_repo">Replace</string>
<string name="action_replace_repo_title">Signing Key Fingerprint Already Exists</string>
<string name="action_replace_repo_message">Repository %1$s has the same Signing Key Fingerprint as %2$s.\nIf this is expected, %2$s will be replaced, otherwise contact your repo maintainer.</string>
<!-- Extension store -->
<string name="extensionStores">Extension stores</string>
<string name="action.addExtensionStore">Add extension store</string>
<string name="extensionStoresScreen.emptyLabel">You haven\'t added an extension store yet</string>
<string name="extensionStoresScreen.addStore.title">Add extension store</string>
<string name="extensionStoresScreen.addStore.alreadyExists">This extension store already exists</string>
<string name="extensionStoresScreen.addStore.processing">Processing…</string>
<string name="extensionStoresScreen.addStoreInput.inputLabel">Store index URL</string>
<string name="extensionStoresScreen.addStoreDeeplink.bodyText">Do you wish to add the extension store below?</string>
<string name="extensionStoresScreen.deleteStore.title">Remove extension store</string>
<string name="extensionStoresScreen.deleteStore.body">Do you wish to remove the extension store \"%1$s\" (%2$s)?</string>
<!-- Reader section -->
<string name="pref_fullscreen">Fullscreen</string>
@@ -566,7 +565,6 @@
<string name="backup_choice">What do you want to backup?</string>
<string name="app_settings">App settings</string>
<string name="source_settings">Source settings</string>
<string name="extensionRepo_settings">Extension repos</string>
<string name="private_settings">Include sensitive settings (e.g., tracker login tokens)</string>
<string name="non_library_settings">All read entries</string>
<string name="creating_backup">Creating backup</string>