mirror of
https://github.com/mihonapp/mihon.git
synced 2024-11-21 20:47:03 -05:00
Grab extension repo detail from repo.json
and include in DB (#506)
* WIP Extension Repo DB Support * Wired in to extension screen, browse settings screen * Detekt changes * Ui tweaks and open in browser * Migrate ExtensionRepos on Update * Migration Cleanup * Slight cleanup / error handling * Update ExtensionRepo from Repo.json during extension search. Added Manual refresh in extension repos page. * Split repo fetching into separate API module, major refactor work * Removed development strings * Moved migration to #3 * Fixed rebase * Detekt changes * Added Replace Repository Dialog * Cleanup, removed platform specific code, PR comments * Removed extra function, reverted small change * Detekt cleanup * Apply suggestions from code review Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com> * Fixed error introduced in cleanup * Tweak for multiline when * Moved getCount() to flow * changed getCount to non-suspend, used property delegation * Apply suggestions from code review Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com> * Fixed formatting with updated comment string * Big wave of PR comments, renaming/other tweaks * onOpenWebsite changes * onOpenWebsite changes * trying to make single line * Renamed ExtensionRepoApi.kt to ExtensionRepoService.kt --------- Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
This commit is contained in:
parent
e75488f5d9
commit
4b4e468510
28 changed files with 649 additions and 81 deletions
|
@ -4,10 +4,7 @@ import eu.kanade.domain.chapter.interactor.GetAvailableScanlators
|
||||||
import eu.kanade.domain.chapter.interactor.SetReadStatus
|
import eu.kanade.domain.chapter.interactor.SetReadStatus
|
||||||
import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
|
import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource
|
||||||
import eu.kanade.domain.download.interactor.DeleteDownload
|
import eu.kanade.domain.download.interactor.DeleteDownload
|
||||||
import eu.kanade.domain.extension.interactor.CreateExtensionRepo
|
|
||||||
import eu.kanade.domain.extension.interactor.DeleteExtensionRepo
|
|
||||||
import eu.kanade.domain.extension.interactor.GetExtensionLanguages
|
import eu.kanade.domain.extension.interactor.GetExtensionLanguages
|
||||||
import eu.kanade.domain.extension.interactor.GetExtensionRepos
|
|
||||||
import eu.kanade.domain.extension.interactor.GetExtensionSources
|
import eu.kanade.domain.extension.interactor.GetExtensionSources
|
||||||
import eu.kanade.domain.extension.interactor.GetExtensionsByType
|
import eu.kanade.domain.extension.interactor.GetExtensionsByType
|
||||||
import eu.kanade.domain.extension.interactor.TrustExtension
|
import eu.kanade.domain.extension.interactor.TrustExtension
|
||||||
|
@ -26,6 +23,14 @@ import eu.kanade.domain.track.interactor.AddTracks
|
||||||
import eu.kanade.domain.track.interactor.RefreshTracks
|
import eu.kanade.domain.track.interactor.RefreshTracks
|
||||||
import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack
|
import eu.kanade.domain.track.interactor.SyncChapterProgressWithTrack
|
||||||
import eu.kanade.domain.track.interactor.TrackChapter
|
import eu.kanade.domain.track.interactor.TrackChapter
|
||||||
|
import mihon.data.repository.ExtensionRepoRepositoryImpl
|
||||||
|
import mihon.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 tachiyomi.data.category.CategoryRepositoryImpl
|
import tachiyomi.data.category.CategoryRepositoryImpl
|
||||||
import tachiyomi.data.chapter.ChapterRepositoryImpl
|
import tachiyomi.data.chapter.ChapterRepositoryImpl
|
||||||
import tachiyomi.data.history.HistoryRepositoryImpl
|
import tachiyomi.data.history.HistoryRepositoryImpl
|
||||||
|
@ -173,8 +178,12 @@ class DomainModule : InjektModule {
|
||||||
addFactory { ToggleSourcePin(get()) }
|
addFactory { ToggleSourcePin(get()) }
|
||||||
addFactory { TrustExtension(get()) }
|
addFactory { TrustExtension(get()) }
|
||||||
|
|
||||||
|
addSingletonFactory<ExtensionRepoRepository> { ExtensionRepoRepositoryImpl(get()) }
|
||||||
|
addFactory { GetExtensionRepo(get()) }
|
||||||
|
addFactory { GetExtensionRepoCount(get()) }
|
||||||
addFactory { CreateExtensionRepo(get()) }
|
addFactory { CreateExtensionRepo(get()) }
|
||||||
addFactory { DeleteExtensionRepo(get()) }
|
addFactory { DeleteExtensionRepo(get()) }
|
||||||
addFactory { GetExtensionRepos(get()) }
|
addFactory { ReplaceExtensionRepo(get()) }
|
||||||
|
addFactory { UpdateExtensionRepo(get(), get()) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,25 +0,0 @@
|
||||||
package eu.kanade.domain.extension.interactor
|
|
||||||
|
|
||||||
import eu.kanade.domain.source.service.SourcePreferences
|
|
||||||
import tachiyomi.core.common.preference.plusAssign
|
|
||||||
|
|
||||||
class CreateExtensionRepo(private val preferences: SourcePreferences) {
|
|
||||||
|
|
||||||
fun await(name: String): Result {
|
|
||||||
// Do not allow invalid formats
|
|
||||||
if (!name.matches(repoRegex)) {
|
|
||||||
return Result.InvalidUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
preferences.extensionRepos() += name.removeSuffix("/index.min.json")
|
|
||||||
|
|
||||||
return Result.Success
|
|
||||||
}
|
|
||||||
|
|
||||||
sealed interface Result {
|
|
||||||
data object InvalidUrl : Result
|
|
||||||
data object Success : Result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val repoRegex = """^https://.*/index\.min\.json$""".toRegex()
|
|
|
@ -1,11 +0,0 @@
|
||||||
package eu.kanade.domain.extension.interactor
|
|
||||||
|
|
||||||
import eu.kanade.domain.source.service.SourcePreferences
|
|
||||||
import tachiyomi.core.common.preference.minusAssign
|
|
||||||
|
|
||||||
class DeleteExtensionRepo(private val preferences: SourcePreferences) {
|
|
||||||
|
|
||||||
fun await(repo: String) {
|
|
||||||
preferences.extensionRepos() -= repo
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
package eu.kanade.domain.extension.interactor
|
|
||||||
|
|
||||||
import eu.kanade.domain.source.service.SourcePreferences
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
|
|
||||||
class GetExtensionRepos(private val preferences: SourcePreferences) {
|
|
||||||
|
|
||||||
fun subscribe(): Flow<Set<String>> {
|
|
||||||
return preferences.extensionRepos().changes()
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -2,6 +2,7 @@ package eu.kanade.presentation.more.settings.screen
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.ReadOnlyComposable
|
import androidx.compose.runtime.ReadOnlyComposable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
@ -13,11 +14,11 @@ import eu.kanade.presentation.more.settings.Preference
|
||||||
import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen
|
import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen
|
||||||
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
|
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
|
||||||
import kotlinx.collections.immutable.persistentListOf
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount
|
||||||
import tachiyomi.core.common.i18n.stringResource
|
import tachiyomi.core.common.i18n.stringResource
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.i18n.pluralStringResource
|
import tachiyomi.presentation.core.i18n.pluralStringResource
|
||||||
import tachiyomi.presentation.core.i18n.stringResource
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
import tachiyomi.presentation.core.util.collectAsState
|
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
|
@ -33,7 +34,9 @@ object SettingsBrowseScreen : SearchableSettings {
|
||||||
val navigator = LocalNavigator.currentOrThrow
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
|
|
||||||
val sourcePreferences = remember { Injekt.get<SourcePreferences>() }
|
val sourcePreferences = remember { Injekt.get<SourcePreferences>() }
|
||||||
val reposCount by sourcePreferences.extensionRepos().collectAsState()
|
val getExtensionRepoCount = remember { Injekt.get<GetExtensionRepoCount>() }
|
||||||
|
|
||||||
|
val reposCount by getExtensionRepoCount.subscribe().collectAsState(0)
|
||||||
|
|
||||||
return listOf(
|
return listOf(
|
||||||
Preference.PreferenceGroup(
|
Preference.PreferenceGroup(
|
||||||
|
@ -45,7 +48,7 @@ object SettingsBrowseScreen : SearchableSettings {
|
||||||
),
|
),
|
||||||
Preference.PreferenceItem.TextPreference(
|
Preference.PreferenceItem.TextPreference(
|
||||||
title = stringResource(MR.strings.label_extension_repos),
|
title = stringResource(MR.strings.label_extension_repos),
|
||||||
subtitle = pluralStringResource(MR.plurals.num_repos, reposCount.size, reposCount.size),
|
subtitle = pluralStringResource(MR.plurals.num_repos, reposCount, reposCount),
|
||||||
onClick = {
|
onClick = {
|
||||||
navigator.push(ExtensionReposScreen())
|
navigator.push(ExtensionReposScreen())
|
||||||
},
|
},
|
||||||
|
|
|
@ -8,11 +8,14 @@ import androidx.compose.ui.platform.LocalContext
|
||||||
import cafe.adriel.voyager.core.model.rememberScreenModel
|
import cafe.adriel.voyager.core.model.rememberScreenModel
|
||||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||||
|
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoConflictDialog
|
||||||
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoCreateDialog
|
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.ExtensionRepoDeleteDialog
|
||||||
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionReposScreen
|
import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionReposScreen
|
||||||
import eu.kanade.presentation.util.Screen
|
import eu.kanade.presentation.util.Screen
|
||||||
|
import eu.kanade.tachiyomi.util.system.openInBrowser
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
|
import kotlinx.collections.immutable.toImmutableSet
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import tachiyomi.presentation.core.screens.LoadingScreen
|
import tachiyomi.presentation.core.screens.LoadingScreen
|
||||||
|
|
||||||
|
@ -42,17 +45,19 @@ class ExtensionReposScreen(
|
||||||
ExtensionReposScreen(
|
ExtensionReposScreen(
|
||||||
state = successState,
|
state = successState,
|
||||||
onClickCreate = { screenModel.showDialog(RepoDialog.Create) },
|
onClickCreate = { screenModel.showDialog(RepoDialog.Create) },
|
||||||
|
onOpenWebsite = { context.openInBrowser(it.website) },
|
||||||
onClickDelete = { screenModel.showDialog(RepoDialog.Delete(it)) },
|
onClickDelete = { screenModel.showDialog(RepoDialog.Delete(it)) },
|
||||||
|
onClickRefresh = { screenModel.refreshRepos() },
|
||||||
navigateUp = navigator::pop,
|
navigateUp = navigator::pop,
|
||||||
)
|
)
|
||||||
|
|
||||||
when (val dialog = successState.dialog) {
|
when (val dialog = successState.dialog) {
|
||||||
null -> {}
|
null -> {}
|
||||||
RepoDialog.Create -> {
|
is RepoDialog.Create -> {
|
||||||
ExtensionRepoCreateDialog(
|
ExtensionRepoCreateDialog(
|
||||||
onDismissRequest = screenModel::dismissDialog,
|
onDismissRequest = screenModel::dismissDialog,
|
||||||
onCreate = { screenModel.createRepo(it) },
|
onCreate = { screenModel.createRepo(it) },
|
||||||
repos = successState.repos,
|
repoUrls = successState.repos.map { it.baseUrl }.toImmutableSet(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is RepoDialog.Delete -> {
|
is RepoDialog.Delete -> {
|
||||||
|
@ -62,6 +67,15 @@ class ExtensionReposScreen(
|
||||||
repo = dialog.repo,
|
repo = dialog.repo,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is RepoDialog.Conflict -> {
|
||||||
|
ExtensionRepoConflictDialog(
|
||||||
|
onDismissRequest = screenModel::dismissDialog,
|
||||||
|
onMigrate = { screenModel.replaceRepo(dialog.newRepo) },
|
||||||
|
oldRepo = dialog.oldRepo,
|
||||||
|
newRepo = dialog.newRepo,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
|
|
|
@ -4,24 +4,29 @@ import androidx.compose.runtime.Immutable
|
||||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||||
import cafe.adriel.voyager.core.model.screenModelScope
|
import cafe.adriel.voyager.core.model.screenModelScope
|
||||||
import dev.icerock.moko.resources.StringResource
|
import dev.icerock.moko.resources.StringResource
|
||||||
import eu.kanade.domain.extension.interactor.CreateExtensionRepo
|
|
||||||
import eu.kanade.domain.extension.interactor.DeleteExtensionRepo
|
|
||||||
import eu.kanade.domain.extension.interactor.GetExtensionRepos
|
|
||||||
import kotlinx.collections.immutable.ImmutableSet
|
import kotlinx.collections.immutable.ImmutableSet
|
||||||
import kotlinx.collections.immutable.toImmutableSet
|
import kotlinx.collections.immutable.toImmutableSet
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.update
|
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.core.common.util.lang.launchIO
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import uy.kohesive.injekt.Injekt
|
import uy.kohesive.injekt.Injekt
|
||||||
import uy.kohesive.injekt.api.get
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
class ExtensionReposScreenModel(
|
class ExtensionReposScreenModel(
|
||||||
private val getExtensionRepos: GetExtensionRepos = Injekt.get(),
|
private val getExtensionRepo: GetExtensionRepo = Injekt.get(),
|
||||||
private val createExtensionRepo: CreateExtensionRepo = Injekt.get(),
|
private val createExtensionRepo: CreateExtensionRepo = Injekt.get(),
|
||||||
private val deleteExtensionRepo: DeleteExtensionRepo = Injekt.get(),
|
private val deleteExtensionRepo: DeleteExtensionRepo = Injekt.get(),
|
||||||
|
private val replaceExtensionRepo: ReplaceExtensionRepo = Injekt.get(),
|
||||||
|
private val updateExtensionRepo: UpdateExtensionRepo = Injekt.get(),
|
||||||
) : StateScreenModel<RepoScreenState>(RepoScreenState.Loading) {
|
) : StateScreenModel<RepoScreenState>(RepoScreenState.Loading) {
|
||||||
|
|
||||||
private val _events: Channel<RepoEvent> = Channel(Int.MAX_VALUE)
|
private val _events: Channel<RepoEvent> = Channel(Int.MAX_VALUE)
|
||||||
|
@ -29,7 +34,7 @@ class ExtensionReposScreenModel(
|
||||||
|
|
||||||
init {
|
init {
|
||||||
screenModelScope.launchIO {
|
screenModelScope.launchIO {
|
||||||
getExtensionRepos.subscribe()
|
getExtensionRepo.subscribeAll()
|
||||||
.collectLatest { repos ->
|
.collectLatest { repos ->
|
||||||
mutableState.update {
|
mutableState.update {
|
||||||
RepoScreenState.Success(
|
RepoScreenState.Success(
|
||||||
|
@ -43,25 +48,51 @@ class ExtensionReposScreenModel(
|
||||||
/**
|
/**
|
||||||
* Creates and adds a new repo to the database.
|
* Creates and adds a new repo to the database.
|
||||||
*
|
*
|
||||||
* @param name The name of the repo to create.
|
* @param baseUrl The baseUrl of the repo to create.
|
||||||
*/
|
*/
|
||||||
fun createRepo(name: String) {
|
fun createRepo(baseUrl: String) {
|
||||||
screenModelScope.launchIO {
|
screenModelScope.launchIO {
|
||||||
when (createExtensionRepo.await(name)) {
|
when (val result = createExtensionRepo.await(baseUrl)) {
|
||||||
is CreateExtensionRepo.Result.InvalidUrl -> _events.send(RepoEvent.InvalidUrl)
|
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 -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes the given repo from the database.
|
* Inserts a repo to the database, replace a matching repo with the same signing key fingerprint if found.
|
||||||
*
|
*
|
||||||
* @param repo The repo to delete.
|
* @param newRepo The repo to insert
|
||||||
*/
|
*/
|
||||||
fun deleteRepo(repo: String) {
|
fun replaceRepo(newRepo: ExtensionRepo) {
|
||||||
screenModelScope.launchIO {
|
screenModelScope.launchIO {
|
||||||
deleteExtensionRepo.await(repo)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -87,11 +118,13 @@ class ExtensionReposScreenModel(
|
||||||
sealed class RepoEvent {
|
sealed class RepoEvent {
|
||||||
sealed class LocalizedMessage(val stringRes: StringResource) : RepoEvent()
|
sealed class LocalizedMessage(val stringRes: StringResource) : RepoEvent()
|
||||||
data object InvalidUrl : LocalizedMessage(MR.strings.invalid_repo_name)
|
data object InvalidUrl : LocalizedMessage(MR.strings.invalid_repo_name)
|
||||||
|
data object RepoAlreadyExists : LocalizedMessage(MR.strings.error_repo_exists)
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class RepoDialog {
|
sealed class RepoDialog {
|
||||||
data object Create : RepoDialog()
|
data object Create : RepoDialog()
|
||||||
data class Delete(val repo: String) : RepoDialog()
|
data class Delete(val repo: String) : RepoDialog()
|
||||||
|
data class Conflict(val oldRepo: ExtensionRepo, val newRepo: ExtensionRepo) : RepoDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class RepoScreenState {
|
sealed class RepoScreenState {
|
||||||
|
@ -101,7 +134,8 @@ sealed class RepoScreenState {
|
||||||
|
|
||||||
@Immutable
|
@Immutable
|
||||||
data class Success(
|
data class Success(
|
||||||
val repos: ImmutableSet<String>,
|
val repos: ImmutableSet<ExtensionRepo>,
|
||||||
|
val oldRepos: ImmutableSet<String>? = null,
|
||||||
val dialog: RepoDialog? = null,
|
val dialog: RepoDialog? = null,
|
||||||
) : RepoScreenState() {
|
) : RepoScreenState() {
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.outlined.Label
|
import androidx.compose.material.icons.automirrored.outlined.Label
|
||||||
|
import androidx.compose.material.icons.automirrored.outlined.OpenInNew
|
||||||
import androidx.compose.material.icons.outlined.ContentCopy
|
import androidx.compose.material.icons.outlined.ContentCopy
|
||||||
import androidx.compose.material.icons.outlined.Delete
|
import androidx.compose.material.icons.outlined.Delete
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
@ -22,15 +23,17 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import eu.kanade.tachiyomi.util.system.copyToClipboard
|
import eu.kanade.tachiyomi.util.system.copyToClipboard
|
||||||
import kotlinx.collections.immutable.ImmutableSet
|
import kotlinx.collections.immutable.ImmutableSet
|
||||||
|
import mihon.domain.extensionrepo.model.ExtensionRepo
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.components.material.padding
|
import tachiyomi.presentation.core.components.material.padding
|
||||||
import tachiyomi.presentation.core.i18n.stringResource
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ExtensionReposContent(
|
fun ExtensionReposContent(
|
||||||
repos: ImmutableSet<String>,
|
repos: ImmutableSet<ExtensionRepo>,
|
||||||
lazyListState: LazyListState,
|
lazyListState: LazyListState,
|
||||||
paddingValues: PaddingValues,
|
paddingValues: PaddingValues,
|
||||||
|
onOpenWebsite: (ExtensionRepo) -> Unit,
|
||||||
onClickDelete: (String) -> Unit,
|
onClickDelete: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
@ -44,8 +47,9 @@ fun ExtensionReposContent(
|
||||||
item {
|
item {
|
||||||
ExtensionRepoListItem(
|
ExtensionRepoListItem(
|
||||||
modifier = Modifier.animateItemPlacement(),
|
modifier = Modifier.animateItemPlacement(),
|
||||||
repo = it,
|
repo = it.name,
|
||||||
onDelete = { onClickDelete(it) },
|
onOpenWebsite = { onOpenWebsite(it) },
|
||||||
|
onDelete = { onClickDelete(it.baseUrl) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -55,6 +59,7 @@ fun ExtensionReposContent(
|
||||||
@Composable
|
@Composable
|
||||||
private fun ExtensionRepoListItem(
|
private fun ExtensionRepoListItem(
|
||||||
repo: String,
|
repo: String,
|
||||||
|
onOpenWebsite: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
@ -74,13 +79,24 @@ private fun ExtensionRepoListItem(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null)
|
Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null)
|
||||||
Text(text = repo, modifier = Modifier.padding(start = MaterialTheme.padding.medium))
|
Text(
|
||||||
|
text = repo,
|
||||||
|
modifier = Modifier.padding(start = MaterialTheme.padding.medium),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.End,
|
horizontalArrangement = Arrangement.End,
|
||||||
) {
|
) {
|
||||||
|
IconButton(onClick = onOpenWebsite) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Outlined.OpenInNew,
|
||||||
|
contentDescription = stringResource(MR.strings.action_open_in_browser),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
val url = "$repo/index.min.json"
|
val url = "$repo/index.min.json"
|
||||||
|
|
|
@ -16,6 +16,7 @@ import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import kotlinx.collections.immutable.ImmutableSet
|
import kotlinx.collections.immutable.ImmutableSet
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import mihon.domain.extensionrepo.model.ExtensionRepo
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.i18n.stringResource
|
import tachiyomi.presentation.core.i18n.stringResource
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
@ -24,12 +25,12 @@ import kotlin.time.Duration.Companion.seconds
|
||||||
fun ExtensionRepoCreateDialog(
|
fun ExtensionRepoCreateDialog(
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
onCreate: (String) -> Unit,
|
onCreate: (String) -> Unit,
|
||||||
repos: ImmutableSet<String>,
|
repoUrls: ImmutableSet<String>,
|
||||||
) {
|
) {
|
||||||
var name by remember { mutableStateOf("") }
|
var name by remember { mutableStateOf("") }
|
||||||
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
val focusRequester = remember { FocusRequester() }
|
||||||
val nameAlreadyExists = remember(name) { repos.contains(name) }
|
val nameAlreadyExists = remember(name) { repoUrls.contains(name) }
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismissRequest,
|
onDismissRequest = onDismissRequest,
|
||||||
|
@ -115,3 +116,36 @@ fun ExtensionRepoDeleteDialog(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
@ -5,12 +5,17 @@ package eu.kanade.presentation.more.settings.screen.browse.components
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Refresh
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import eu.kanade.presentation.category.components.CategoryFloatingActionButton
|
import eu.kanade.presentation.category.components.CategoryFloatingActionButton
|
||||||
import eu.kanade.presentation.components.AppBar
|
import eu.kanade.presentation.components.AppBar
|
||||||
import eu.kanade.presentation.more.settings.screen.browse.RepoScreenState
|
import eu.kanade.presentation.more.settings.screen.browse.RepoScreenState
|
||||||
|
import mihon.domain.extensionrepo.model.ExtensionRepo
|
||||||
import tachiyomi.i18n.MR
|
import tachiyomi.i18n.MR
|
||||||
import tachiyomi.presentation.core.components.material.Scaffold
|
import tachiyomi.presentation.core.components.material.Scaffold
|
||||||
import tachiyomi.presentation.core.components.material.padding
|
import tachiyomi.presentation.core.components.material.padding
|
||||||
|
@ -23,7 +28,9 @@ import tachiyomi.presentation.core.util.plus
|
||||||
fun ExtensionReposScreen(
|
fun ExtensionReposScreen(
|
||||||
state: RepoScreenState.Success,
|
state: RepoScreenState.Success,
|
||||||
onClickCreate: () -> Unit,
|
onClickCreate: () -> Unit,
|
||||||
|
onOpenWebsite: (ExtensionRepo) -> Unit,
|
||||||
onClickDelete: (String) -> Unit,
|
onClickDelete: (String) -> Unit,
|
||||||
|
onClickRefresh: () -> Unit,
|
||||||
navigateUp: () -> Unit,
|
navigateUp: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val lazyListState = rememberLazyListState()
|
val lazyListState = rememberLazyListState()
|
||||||
|
@ -33,6 +40,14 @@ fun ExtensionReposScreen(
|
||||||
navigateUp = navigateUp,
|
navigateUp = navigateUp,
|
||||||
title = stringResource(MR.strings.label_extension_repos),
|
title = stringResource(MR.strings.label_extension_repos),
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = onClickRefresh) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Refresh,
|
||||||
|
contentDescription = stringResource(resource = MR.strings.action_webview_refresh),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
|
@ -55,6 +70,7 @@ fun ExtensionReposScreen(
|
||||||
lazyListState = lazyListState,
|
lazyListState = lazyListState,
|
||||||
paddingValues = paddingValues + topSmallPaddingValues +
|
paddingValues = paddingValues + topSmallPaddingValues +
|
||||||
PaddingValues(horizontal = MaterialTheme.padding.medium),
|
PaddingValues(horizontal = MaterialTheme.padding.medium),
|
||||||
|
onOpenWebsite = onOpenWebsite,
|
||||||
onClickDelete = onClickDelete,
|
onClickDelete = onClickDelete,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,18 @@
|
||||||
package eu.kanade.tachiyomi
|
package eu.kanade.tachiyomi
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import eu.kanade.domain.source.service.SourcePreferences
|
||||||
import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob
|
import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob
|
||||||
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
|
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import logcat.LogPriority
|
||||||
|
import mihon.domain.extensionrepo.exception.SaveExtensionRepoException
|
||||||
|
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
|
||||||
import tachiyomi.core.common.preference.Preference
|
import tachiyomi.core.common.preference.Preference
|
||||||
import tachiyomi.core.common.preference.PreferenceStore
|
import tachiyomi.core.common.preference.PreferenceStore
|
||||||
|
import tachiyomi.core.common.util.lang.launchIO
|
||||||
|
import tachiyomi.core.common.util.system.logcat
|
||||||
|
|
||||||
object Migrations {
|
object Migrations {
|
||||||
|
|
||||||
|
@ -13,10 +21,12 @@ object Migrations {
|
||||||
*
|
*
|
||||||
* @return true if a migration is performed, false otherwise.
|
* @return true if a migration is performed, false otherwise.
|
||||||
*/
|
*/
|
||||||
@Suppress("SameReturnValue")
|
@Suppress("SameReturnValue", "MagicNumber")
|
||||||
fun upgrade(
|
fun upgrade(
|
||||||
context: Context,
|
context: Context,
|
||||||
preferenceStore: PreferenceStore,
|
preferenceStore: PreferenceStore,
|
||||||
|
sourcePreferences: SourcePreferences,
|
||||||
|
extensionRepoRepository: ExtensionRepoRepository,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val lastVersionCode = preferenceStore.getInt(Preference.appStateKey("last_version_code"), 0)
|
val lastVersionCode = preferenceStore.getInt(Preference.appStateKey("last_version_code"), 0)
|
||||||
val oldVersion = lastVersionCode.get()
|
val oldVersion = lastVersionCode.get()
|
||||||
|
@ -31,6 +41,27 @@ object Migrations {
|
||||||
if (oldVersion == 0) {
|
if (oldVersion == 0) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val coroutineScope = CoroutineScope(Dispatchers.IO)
|
||||||
|
|
||||||
|
if (oldVersion < 6) {
|
||||||
|
coroutineScope.launchIO {
|
||||||
|
for ((index, source) in sourcePreferences.extensionRepos().get().withIndex()) {
|
||||||
|
try {
|
||||||
|
extensionRepoRepository.upsertRepository(
|
||||||
|
source,
|
||||||
|
"Repo #${index + 1}",
|
||||||
|
null,
|
||||||
|
source,
|
||||||
|
"NOFINGERPRINT-${index + 1}",
|
||||||
|
)
|
||||||
|
} catch (e: SaveExtensionRepoException) {
|
||||||
|
logcat(LogPriority.ERROR, e) { "Error Migrating Extension Repo with baseUrl: $source" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sourcePreferences.extensionRepos().delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
package eu.kanade.tachiyomi.extension.api
|
package eu.kanade.tachiyomi.extension.api
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import eu.kanade.domain.source.service.SourcePreferences
|
|
||||||
import eu.kanade.tachiyomi.extension.ExtensionManager
|
import eu.kanade.tachiyomi.extension.ExtensionManager
|
||||||
import eu.kanade.tachiyomi.extension.model.Extension
|
import eu.kanade.tachiyomi.extension.model.Extension
|
||||||
import eu.kanade.tachiyomi.extension.model.LoadResult
|
import eu.kanade.tachiyomi.extension.model.LoadResult
|
||||||
|
@ -10,9 +9,14 @@ import eu.kanade.tachiyomi.network.GET
|
||||||
import eu.kanade.tachiyomi.network.NetworkHelper
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||||
import eu.kanade.tachiyomi.network.parseAs
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import logcat.LogPriority
|
import logcat.LogPriority
|
||||||
|
import mihon.domain.extensionrepo.interactor.GetExtensionRepo
|
||||||
|
import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo
|
||||||
|
import mihon.domain.extensionrepo.model.ExtensionRepo
|
||||||
import tachiyomi.core.common.preference.Preference
|
import tachiyomi.core.common.preference.Preference
|
||||||
import tachiyomi.core.common.preference.PreferenceStore
|
import tachiyomi.core.common.preference.PreferenceStore
|
||||||
import tachiyomi.core.common.util.lang.withIOContext
|
import tachiyomi.core.common.util.lang.withIOContext
|
||||||
|
@ -25,7 +29,8 @@ internal class ExtensionApi {
|
||||||
|
|
||||||
private val networkService: NetworkHelper by injectLazy()
|
private val networkService: NetworkHelper by injectLazy()
|
||||||
private val preferenceStore: PreferenceStore by injectLazy()
|
private val preferenceStore: PreferenceStore by injectLazy()
|
||||||
private val sourcePreferences: SourcePreferences by injectLazy()
|
private val getExtensionRepo: GetExtensionRepo by injectLazy()
|
||||||
|
private val updateExtensionRepo: UpdateExtensionRepo by injectLazy()
|
||||||
private val extensionManager: ExtensionManager by injectLazy()
|
private val extensionManager: ExtensionManager by injectLazy()
|
||||||
private val json: Json by injectLazy()
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
|
@ -35,11 +40,15 @@ internal class ExtensionApi {
|
||||||
|
|
||||||
suspend fun findExtensions(): List<Extension.Available> {
|
suspend fun findExtensions(): List<Extension.Available> {
|
||||||
return withIOContext {
|
return withIOContext {
|
||||||
sourcePreferences.extensionRepos().get().flatMap { getExtensions(it) }
|
getExtensionRepo.getAll()
|
||||||
|
.map { async { getExtensions(it) } }
|
||||||
|
.awaitAll()
|
||||||
|
.flatten()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getExtensions(repoBaseUrl: String): List<Extension.Available> {
|
private suspend fun getExtensions(extRepo: ExtensionRepo): List<Extension.Available> {
|
||||||
|
val repoBaseUrl = extRepo.baseUrl
|
||||||
return try {
|
return try {
|
||||||
val response = networkService.client
|
val response = networkService.client
|
||||||
.newCall(GET("$repoBaseUrl/index.min.json"))
|
.newCall(GET("$repoBaseUrl/index.min.json"))
|
||||||
|
@ -67,6 +76,9 @@ internal class ExtensionApi {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update extension repo details
|
||||||
|
updateExtensionRepo.awaitAll()
|
||||||
|
|
||||||
val extensions = if (fromAvailableExtensionList) {
|
val extensions = if (fromAvailableExtensionList) {
|
||||||
extensionManager.availableExtensionsFlow.value
|
extensionManager.availableExtensionsFlow.value
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -134,6 +134,8 @@ class MainActivity : BaseActivity() {
|
||||||
Migrations.upgrade(
|
Migrations.upgrade(
|
||||||
context = applicationContext,
|
context = applicationContext,
|
||||||
preferenceStore = Injekt.get(),
|
preferenceStore = Injekt.get(),
|
||||||
|
sourcePreferences = Injekt.get(),
|
||||||
|
extensionRepoRepository = Injekt.get(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
|
|
|
@ -0,0 +1,93 @@
|
||||||
|
package mihon.data.repository
|
||||||
|
|
||||||
|
import android.database.sqlite.SQLiteException
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import mihon.domain.extensionrepo.exception.SaveExtensionRepoException
|
||||||
|
import mihon.domain.extensionrepo.model.ExtensionRepo
|
||||||
|
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
|
||||||
|
import tachiyomi.data.DatabaseHandler
|
||||||
|
|
||||||
|
class ExtensionRepoRepositoryImpl(
|
||||||
|
private val handler: DatabaseHandler,
|
||||||
|
) : ExtensionRepoRepository {
|
||||||
|
override fun subscribeAll(): Flow<List<ExtensionRepo>> {
|
||||||
|
return handler.subscribeToList { extension_reposQueries.findAll(::mapExtensionRepo) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAll(): List<ExtensionRepo> {
|
||||||
|
return handler.awaitList { extension_reposQueries.findAll(::mapExtensionRepo) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getRepository(baseUrl: String): ExtensionRepo? {
|
||||||
|
return handler.awaitOneOrNull { extension_reposQueries.findOne(baseUrl, ::mapExtensionRepo) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getRepositoryBySigningKeyFingerprint(fingerprint: String): ExtensionRepo? {
|
||||||
|
return handler.awaitOneOrNull {
|
||||||
|
extension_reposQueries.findOneBySigningKeyFingerprint(fingerprint, ::mapExtensionRepo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCount(): Flow<Int> {
|
||||||
|
return handler.subscribeToOne { extension_reposQueries.count() }.map { it.toInt() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun insertRepository(
|
||||||
|
baseUrl: String,
|
||||||
|
name: String,
|
||||||
|
shortName: String?,
|
||||||
|
website: String,
|
||||||
|
signingKeyFingerprint: String,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
handler.await { extension_reposQueries.insert(baseUrl, name, shortName, website, signingKeyFingerprint) }
|
||||||
|
} catch (ex: SQLiteException) {
|
||||||
|
throw SaveExtensionRepoException(ex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun upsertRepository(
|
||||||
|
baseUrl: String,
|
||||||
|
name: String,
|
||||||
|
shortName: String?,
|
||||||
|
website: String,
|
||||||
|
signingKeyFingerprint: String,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
handler.await { extension_reposQueries.upsert(baseUrl, name, shortName, website, signingKeyFingerprint) }
|
||||||
|
} catch (ex: SQLiteException) {
|
||||||
|
throw SaveExtensionRepoException(ex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun replaceRepository(newRepo: ExtensionRepo) {
|
||||||
|
handler.await {
|
||||||
|
extension_reposQueries.replace(
|
||||||
|
newRepo.baseUrl,
|
||||||
|
newRepo.name,
|
||||||
|
newRepo.shortName,
|
||||||
|
newRepo.website,
|
||||||
|
newRepo.signingKeyFingerprint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteRepository(baseUrl: String) {
|
||||||
|
return handler.await { extension_reposQueries.delete(baseUrl) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mapExtensionRepo(
|
||||||
|
baseUrl: String,
|
||||||
|
name: String,
|
||||||
|
shortName: String?,
|
||||||
|
website: String,
|
||||||
|
signingKeyFingerprint: String,
|
||||||
|
): ExtensionRepo = ExtensionRepo(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
name = name,
|
||||||
|
shortName = shortName,
|
||||||
|
website = website,
|
||||||
|
signingKeyFingerprint = signingKeyFingerprint,
|
||||||
|
)
|
||||||
|
}
|
57
data/src/main/sqldelight/tachiyomi/data/extension_repos.sq
Normal file
57
data/src/main/sqldelight/tachiyomi/data/extension_repos.sq
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
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;
|
8
data/src/main/sqldelight/tachiyomi/migrations/3.sqm
Normal file
8
data/src/main/sqldelight/tachiyomi/migrations/3.sqm
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
-- Create ExtensionRepo table --
|
||||||
|
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
|
||||||
|
);
|
|
@ -33,6 +33,7 @@ tasks {
|
||||||
withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
|
withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
|
||||||
kotlinOptions.freeCompilerArgs += listOf(
|
kotlinOptions.freeCompilerArgs += listOf(
|
||||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||||
|
"-Xcontext-receivers",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
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)
|
|
@ -0,0 +1,81 @@
|
||||||
|
package mihon.domain.extensionrepo.interactor
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
|
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.OkHttpClient
|
||||||
|
import tachiyomi.core.common.util.system.logcat
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
class CreateExtensionRepo(
|
||||||
|
private val extensionRepoRepository: ExtensionRepoRepository,
|
||||||
|
) {
|
||||||
|
private val repoRegex = """^https://.*/index\.min\.json$""".toRegex()
|
||||||
|
|
||||||
|
private val networkService: NetworkHelper by injectLazy()
|
||||||
|
|
||||||
|
private val client: OkHttpClient
|
||||||
|
get() = networkService.client
|
||||||
|
|
||||||
|
private val extensionRepoService = ExtensionRepoService(client)
|
||||||
|
|
||||||
|
suspend fun await(repoUrl: String): Result {
|
||||||
|
if (!repoUrl.matches(repoRegex)) {
|
||||||
|
return Result.InvalidUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
val baseUrl = repoUrl.removeSuffix("/index.min.json")
|
||||||
|
return extensionRepoService.fetchRepoDetails(baseUrl)?.let { insert(it) } ?: Result.InvalidUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun insert(repo: ExtensionRepo): Result {
|
||||||
|
return try {
|
||||||
|
extensionRepoRepository.insertRepository(
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
@Suppress("ReturnCount")
|
||||||
|
private suspend fun handleInsertionError(repo: ExtensionRepo): Result {
|
||||||
|
val repoExists = extensionRepoRepository.getRepository(repo.baseUrl)
|
||||||
|
if (repoExists != null) {
|
||||||
|
return Result.RepoAlreadyExists
|
||||||
|
}
|
||||||
|
val matchingFingerprintRepo =
|
||||||
|
extensionRepoRepository.getRepositoryBySigningKeyFingerprint(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
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
package mihon.domain.extensionrepo.interactor
|
||||||
|
|
||||||
|
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
|
||||||
|
|
||||||
|
class DeleteExtensionRepo(
|
||||||
|
private val extensionRepoRepository: ExtensionRepoRepository,
|
||||||
|
) {
|
||||||
|
suspend fun await(baseUrl: String) {
|
||||||
|
extensionRepoRepository.deleteRepository(baseUrl)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
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 extensionRepoRepository: ExtensionRepoRepository,
|
||||||
|
) {
|
||||||
|
fun subscribeAll(): Flow<List<ExtensionRepo>> = extensionRepoRepository.subscribeAll()
|
||||||
|
|
||||||
|
suspend fun getAll(): List<ExtensionRepo> = extensionRepoRepository.getAll()
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
package mihon.domain.extensionrepo.interactor
|
||||||
|
|
||||||
|
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
|
||||||
|
|
||||||
|
class GetExtensionRepoCount(
|
||||||
|
private val extensionRepoRepository: ExtensionRepoRepository,
|
||||||
|
) {
|
||||||
|
fun subscribe() = extensionRepoRepository.getCount()
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
package mihon.domain.extensionrepo.interactor
|
||||||
|
|
||||||
|
import mihon.domain.extensionrepo.model.ExtensionRepo
|
||||||
|
import mihon.domain.extensionrepo.repository.ExtensionRepoRepository
|
||||||
|
|
||||||
|
class ReplaceExtensionRepo(
|
||||||
|
private val extensionRepoRepository: ExtensionRepoRepository,
|
||||||
|
) {
|
||||||
|
suspend fun await(repo: ExtensionRepo) {
|
||||||
|
extensionRepoRepository.replaceRepository(repo)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
package mihon.domain.extensionrepo.interactor
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.network.NetworkHelper
|
||||||
|
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 extensionRepoRepository: ExtensionRepoRepository,
|
||||||
|
networkService: NetworkHelper,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val extensionRepoService = ExtensionRepoService(networkService.client)
|
||||||
|
|
||||||
|
suspend fun awaitAll() = coroutineScope {
|
||||||
|
extensionRepoRepository.getAll()
|
||||||
|
.map { async { await(it) } }
|
||||||
|
.awaitAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun await(repo: ExtensionRepo) {
|
||||||
|
val newRepo = extensionRepoService.fetchRepoDetails(repo.baseUrl) ?: return
|
||||||
|
if (
|
||||||
|
repo.signingKeyFingerprint.startsWith("NOFINGERPRINT") ||
|
||||||
|
repo.signingKeyFingerprint == newRepo.signingKeyFingerprint
|
||||||
|
) {
|
||||||
|
extensionRepoRepository.upsertRepository(newRepo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
package mihon.domain.extensionrepo.model
|
||||||
|
|
||||||
|
data class ExtensionRepo(
|
||||||
|
val baseUrl: String,
|
||||||
|
val name: String,
|
||||||
|
val shortName: String?,
|
||||||
|
val website: String,
|
||||||
|
val signingKeyFingerprint: String,
|
||||||
|
)
|
|
@ -0,0 +1,47 @@
|
||||||
|
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 getRepository(baseUrl: String): ExtensionRepo?
|
||||||
|
|
||||||
|
suspend fun getRepositoryBySigningKeyFingerprint(fingerprint: String): ExtensionRepo?
|
||||||
|
|
||||||
|
fun getCount(): Flow<Int>
|
||||||
|
|
||||||
|
suspend fun insertRepository(
|
||||||
|
baseUrl: String,
|
||||||
|
name: String,
|
||||||
|
shortName: String?,
|
||||||
|
website: String,
|
||||||
|
signingKeyFingerprint: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun upsertRepository(
|
||||||
|
baseUrl: String,
|
||||||
|
name: String,
|
||||||
|
shortName: String?,
|
||||||
|
website: String,
|
||||||
|
signingKeyFingerprint: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun upsertRepository(repo: ExtensionRepo) {
|
||||||
|
upsertRepository(
|
||||||
|
baseUrl = repo.baseUrl,
|
||||||
|
name = repo.name,
|
||||||
|
shortName = repo.shortName,
|
||||||
|
website = repo.website,
|
||||||
|
signingKeyFingerprint = repo.signingKeyFingerprint,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun replaceRepository(newRepo: ExtensionRepo)
|
||||||
|
|
||||||
|
suspend fun deleteRepository(baseUrl: String)
|
||||||
|
}
|
|
@ -0,0 +1,57 @@
|
||||||
|
package mihon.domain.extensionrepo.service
|
||||||
|
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.HttpException
|
||||||
|
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||||
|
import eu.kanade.tachiyomi.network.parseAs
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import mihon.domain.extensionrepo.model.ExtensionRepo
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import tachiyomi.core.common.util.lang.withIOContext
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
|
||||||
|
class ExtensionRepoService(
|
||||||
|
private val client: OkHttpClient,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
|
suspend fun fetchRepoDetails(
|
||||||
|
repo: String,
|
||||||
|
): ExtensionRepo? {
|
||||||
|
return withIOContext {
|
||||||
|
val url = "$repo/repo.json".toUri()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val response = with(json) {
|
||||||
|
client.newCall(GET(url.toString()))
|
||||||
|
.awaitSuccess()
|
||||||
|
.parseAs<JsonObject>()
|
||||||
|
}
|
||||||
|
response["meta"]
|
||||||
|
?.jsonObject
|
||||||
|
?.let { jsonToExtensionRepo(baseUrl = repo, it) }
|
||||||
|
} catch (_: HttpException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonToExtensionRepo(baseUrl: String, obj: JsonObject): ExtensionRepo? {
|
||||||
|
return try {
|
||||||
|
ExtensionRepo(
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
name = obj["name"]!!.jsonPrimitive.content,
|
||||||
|
shortName = obj["shortName"]?.jsonPrimitive?.content,
|
||||||
|
website = obj["website"]!!.jsonPrimitive.content,
|
||||||
|
signingKeyFingerprint = obj["signingKeyFingerprint"]!!.jsonPrimitive.content,
|
||||||
|
)
|
||||||
|
} catch (_: NullPointerException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -347,6 +347,9 @@
|
||||||
<string name="invalid_repo_name">Invalid repo URL</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="delete_repo_confirmation">Do you wish to delete the repo \"%s\"?</string>
|
||||||
<string name="action_open_repo">Open source repo</string>
|
<string name="action_open_repo">Open source repo</string>
|
||||||
|
<string name="action_replace_repo">Replace</string>
|
||||||
|
<string name="action_replace_repo_title">Signing Key Fingerprint Already Exists</string>
|
||||||
|
<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>
|
||||||
|
|
||||||
<!-- Reader section -->
|
<!-- Reader section -->
|
||||||
<string name="pref_fullscreen">Fullscreen</string>
|
<string name="pref_fullscreen">Fullscreen</string>
|
||||||
|
|
Loading…
Reference in a new issue