diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 932d41962..295ca9ecd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,7 +26,7 @@ android { defaultConfig { applicationId = "eu.kanade.tachiyomi.sy" - versionCode = 66 + versionCode = 67 versionName = "1.10.5" buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"") diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 0f6f2b9ed..5c6893db1 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -4,10 +4,7 @@ import eu.kanade.domain.chapter.interactor.GetAvailableScanlators import eu.kanade.domain.chapter.interactor.SetReadStatus import eu.kanade.domain.chapter.interactor.SyncChaptersWithSource 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.GetExtensionRepos import eu.kanade.domain.extension.interactor.GetExtensionSources import eu.kanade.domain.extension.interactor.GetExtensionsByType 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.SyncChapterProgressWithTrack 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.chapter.ChapterRepositoryImpl import tachiyomi.data.history.HistoryRepositoryImpl @@ -173,8 +178,12 @@ class DomainModule : InjektModule { addFactory { ToggleSourcePin(get()) } addFactory { TrustExtension(get()) } + addSingletonFactory { ExtensionRepoRepositoryImpl(get()) } + addFactory { GetExtensionRepo(get()) } + addFactory { GetExtensionRepoCount(get()) } addFactory { CreateExtensionRepo(get()) } addFactory { DeleteExtensionRepo(get()) } - addFactory { GetExtensionRepos(get()) } + addFactory { ReplaceExtensionRepo(get()) } + addFactory { UpdateExtensionRepo(get(), get()) } } } diff --git a/app/src/main/java/eu/kanade/domain/extension/interactor/CreateExtensionRepo.kt b/app/src/main/java/eu/kanade/domain/extension/interactor/CreateExtensionRepo.kt deleted file mode 100644 index a8083ec00..000000000 --- a/app/src/main/java/eu/kanade/domain/extension/interactor/CreateExtensionRepo.kt +++ /dev/null @@ -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() diff --git a/app/src/main/java/eu/kanade/domain/extension/interactor/DeleteExtensionRepo.kt b/app/src/main/java/eu/kanade/domain/extension/interactor/DeleteExtensionRepo.kt deleted file mode 100644 index 8e50ebeca..000000000 --- a/app/src/main/java/eu/kanade/domain/extension/interactor/DeleteExtensionRepo.kt +++ /dev/null @@ -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 - } -} diff --git a/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionRepos.kt b/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionRepos.kt deleted file mode 100644 index 0d3b0e988..000000000 --- a/app/src/main/java/eu/kanade/domain/extension/interactor/GetExtensionRepos.kt +++ /dev/null @@ -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> { - return preferences.extensionRepos().changes() - } -} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt index 4deff07a2..f5bc96eb0 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt @@ -2,6 +2,7 @@ package eu.kanade.presentation.more.settings.screen import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -17,13 +18,13 @@ import eu.kanade.presentation.more.settings.screen.browse.ExtensionReposScreen import eu.kanade.tachiyomi.ui.category.sources.SourceCategoryScreen import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate import kotlinx.collections.immutable.persistentListOf +import mihon.domain.extensionrepo.interactor.GetExtensionRepoCount import tachiyomi.core.common.i18n.stringResource import tachiyomi.domain.UnsortedPreferences import tachiyomi.i18n.MR import tachiyomi.i18n.sy.SYMR import tachiyomi.presentation.core.i18n.pluralStringResource import tachiyomi.presentation.core.i18n.stringResource -import tachiyomi.presentation.core.util.collectAsState import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -39,7 +40,9 @@ object SettingsBrowseScreen : SearchableSettings { val navigator = LocalNavigator.currentOrThrow val sourcePreferences = remember { Injekt.get() } - val reposCount by sourcePreferences.extensionRepos().collectAsState() + val getExtensionRepoCount = remember { Injekt.get() } + + val reposCount by getExtensionRepoCount.subscribe().collectAsState(0) // SY --> val scope = rememberCoroutineScope() @@ -104,7 +107,7 @@ object SettingsBrowseScreen : SearchableSettings { ), Preference.PreferenceItem.TextPreference( 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 = { navigator.push(ExtensionReposScreen()) }, diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreen.kt index 4801829d5..03c9acd7c 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreen.kt @@ -8,11 +8,14 @@ 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.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 @@ -42,17 +45,19 @@ class ExtensionReposScreen( 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 -> {} - RepoDialog.Create -> { + is RepoDialog.Create -> { ExtensionRepoCreateDialog( onDismissRequest = screenModel::dismissDialog, onCreate = { screenModel.createRepo(it) }, - repos = successState.repos, + repoUrls = successState.repos.map { it.baseUrl }.toImmutableSet(), ) } is RepoDialog.Delete -> { @@ -62,6 +67,15 @@ class ExtensionReposScreen( repo = dialog.repo, ) } + + is RepoDialog.Conflict -> { + ExtensionRepoConflictDialog( + onDismissRequest = screenModel::dismissDialog, + onMigrate = { screenModel.replaceRepo(dialog.newRepo) }, + oldRepo = dialog.oldRepo, + newRepo = dialog.newRepo, + ) + } } LaunchedEffect(Unit) { diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreenModel.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreenModel.kt index d694618fb..4131bc6fd 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreenModel.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreenModel.kt @@ -4,24 +4,29 @@ import androidx.compose.runtime.Immutable import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.screenModelScope import dev.icerock.moko.resources.StringResource -import eu.kanade.domain.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.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 getExtensionRepos: GetExtensionRepos = Injekt.get(), + 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(), ) : StateScreenModel(RepoScreenState.Loading) { private val _events: Channel = Channel(Int.MAX_VALUE) @@ -29,7 +34,7 @@ class ExtensionReposScreenModel( init { screenModelScope.launchIO { - getExtensionRepos.subscribe() + getExtensionRepo.subscribeAll() .collectLatest { repos -> mutableState.update { RepoScreenState.Success( @@ -43,25 +48,51 @@ class ExtensionReposScreenModel( /** * 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 { - when (createExtensionRepo.await(name)) { - is CreateExtensionRepo.Result.InvalidUrl -> _events.send(RepoEvent.InvalidUrl) + when (val result = createExtensionRepo.await(baseUrl)) { + 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 -> {} } } } /** - * 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 { - 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 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() } sealed class RepoScreenState { @@ -101,7 +134,8 @@ sealed class RepoScreenState { @Immutable data class Success( - val repos: ImmutableSet, + val repos: ImmutableSet, + val oldRepos: ImmutableSet? = null, val dialog: RepoDialog? = null, ) : RepoScreenState() { diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposContent.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposContent.kt index 7d837b32d..83be8846d 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposContent.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposContent.kt @@ -9,6 +9,7 @@ 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.material3.ElevatedCard @@ -22,15 +23,17 @@ 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 tachiyomi.i18n.MR import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.i18n.stringResource @Composable fun ExtensionReposContent( - repos: ImmutableSet, + repos: ImmutableSet, lazyListState: LazyListState, paddingValues: PaddingValues, + onOpenWebsite: (ExtensionRepo) -> Unit, onClickDelete: (String) -> Unit, modifier: Modifier = Modifier, ) { @@ -44,8 +47,9 @@ fun ExtensionReposContent( item { ExtensionRepoListItem( modifier = Modifier.animateItemPlacement(), - repo = it, - onDelete = { onClickDelete(it) }, + repo = it.name, + onOpenWebsite = { onOpenWebsite(it) }, + onDelete = { onClickDelete(it.baseUrl) }, ) } } @@ -55,6 +59,7 @@ fun ExtensionReposContent( @Composable private fun ExtensionRepoListItem( repo: String, + onOpenWebsite: () -> Unit, onDelete: () -> Unit, modifier: Modifier = Modifier, ) { @@ -74,13 +79,24 @@ private fun ExtensionRepoListItem( verticalAlignment = Alignment.CenterVertically, ) { 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( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, ) { + IconButton(onClick = onOpenWebsite) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.OpenInNew, + contentDescription = stringResource(MR.strings.action_open_in_browser), + ) + } + IconButton( onClick = { val url = "$repo/index.min.json" diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposDialogs.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposDialogs.kt index b4ef8b575..5022f44f0 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposDialogs.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposDialogs.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester 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 @@ -24,12 +25,12 @@ import kotlin.time.Duration.Companion.seconds fun ExtensionRepoCreateDialog( onDismissRequest: () -> Unit, onCreate: (String) -> Unit, - repos: ImmutableSet, + repoUrls: ImmutableSet, ) { var name by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } - val nameAlreadyExists = remember(name) { repos.contains(name) } + val nameAlreadyExists = remember(name) { repoUrls.contains(name) } AlertDialog( 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)) + }, + ) +} diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposScreen.kt index 1bd680d06..b07ba4101 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposScreen.kt @@ -5,12 +5,17 @@ package eu.kanade.presentation.more.settings.screen.browse.components import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding 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.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 tachiyomi.i18n.MR import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.components.material.padding @@ -23,7 +28,9 @@ import tachiyomi.presentation.core.util.plus fun ExtensionReposScreen( state: RepoScreenState.Success, onClickCreate: () -> Unit, + onOpenWebsite: (ExtensionRepo) -> Unit, onClickDelete: (String) -> Unit, + onClickRefresh: () -> Unit, navigateUp: () -> Unit, ) { val lazyListState = rememberLazyListState() @@ -33,6 +40,14 @@ fun ExtensionReposScreen( navigateUp = navigateUp, title = stringResource(MR.strings.label_extension_repos), scrollBehavior = scrollBehavior, + actions = { + IconButton(onClick = onClickRefresh) { + Icon( + imageVector = Icons.Outlined.Refresh, + contentDescription = stringResource(resource = MR.strings.action_webview_refresh), + ) + } + }, ) }, floatingActionButton = { @@ -55,6 +70,7 @@ fun ExtensionReposScreen( lazyListState = lazyListState, paddingValues = paddingValues + topSmallPaddingValues + PaddingValues(horizontal = MaterialTheme.padding.medium), + onOpenWebsite = onOpenWebsite, onClickDelete = onClickDelete, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt index 192274e3e..6e03b3c33 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt @@ -1,34 +1,18 @@ package eu.kanade.tachiyomi import android.content.Context -import androidx.core.content.edit -import androidx.preference.PreferenceManager -import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.source.service.SourcePreferences -import eu.kanade.domain.ui.UiPreferences -import eu.kanade.tachiyomi.core.security.SecurityPreferences import eu.kanade.tachiyomi.data.backup.create.BackupCreateJob import eu.kanade.tachiyomi.data.library.LibraryUpdateJob -import eu.kanade.tachiyomi.data.track.TrackerManager -import eu.kanade.tachiyomi.network.NetworkPreferences -import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE -import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation -import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences -import eu.kanade.tachiyomi.util.system.DeviceUtil -import eu.kanade.tachiyomi.util.system.toast -import eu.kanade.tachiyomi.util.system.workManager +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.PreferenceStore -import tachiyomi.core.common.preference.TriState -import tachiyomi.core.common.preference.getAndSet -import tachiyomi.core.common.preference.getEnum -import tachiyomi.core.common.preference.minusAssign -import tachiyomi.core.common.preference.plusAssign -import tachiyomi.domain.backup.service.BackupPreferences -import tachiyomi.domain.library.service.LibraryPreferences -import tachiyomi.domain.library.service.LibraryPreferences.Companion.MANGA_NON_COMPLETED -import tachiyomi.i18n.MR -import java.io.File +import tachiyomi.core.common.util.lang.launchIO +import tachiyomi.core.common.util.system.logcat object Migrations { @@ -39,18 +23,12 @@ object Migrations { * * @return true if a migration is performed, false otherwise. */ + @Suppress("SameReturnValue", "MagicNumber") fun upgrade( context: Context, preferenceStore: PreferenceStore, - basePreferences: BasePreferences, - uiPreferences: UiPreferences, - networkPreferences: NetworkPreferences, sourcePreferences: SourcePreferences, - securityPreferences: SecurityPreferences, - libraryPreferences: LibraryPreferences, - readerPreferences: ReaderPreferences, - backupPreferences: BackupPreferences, - trackerManager: TrackerManager, + extensionRepoRepository: ExtensionRepoRepository, ): Boolean { val lastVersionCode = preferenceStore.getInt(Preference.appStateKey("last_version_code"), 0) val oldVersion = lastVersionCode.get() @@ -66,399 +44,28 @@ object Migrations { return false } - val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val coroutineScope = CoroutineScope(Dispatchers.IO) - if (oldVersion < 15) { - // Delete internal chapter cache dir. - File(context.cacheDir, "chapter_disk_cache").deleteRecursively() - } - if (oldVersion < 19) { - // Move covers to external files dir. - val oldDir = File(context.externalCacheDir, "cover_disk_cache") - if (oldDir.exists()) { - val destDir = context.getExternalFilesDir("covers") - if (destDir != null) { - oldDir.listFiles()?.forEach { - it.renameTo(File(destDir, it.name)) + 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() } } - if (oldVersion < 26) { - // Delete external chapter cache dir. - val extCache = context.externalCacheDir - if (extCache != null) { - val chapterCache = File(extCache, "chapter_disk_cache") - if (chapterCache.exists()) { - chapterCache.deleteRecursively() - } - } - } - if (oldVersion < 44) { - // Reset sorting preference if using removed sort by source - val oldSortingMode = prefs.getInt(libraryPreferences.sortingMode().key(), 0) - - if (oldSortingMode == 5) { // SOURCE = 5 - prefs.edit { - putInt(libraryPreferences.sortingMode().key(), 0) // ALPHABETICAL = 0 - } - } - } - if (oldVersion < 52) { - // Migrate library filters to tri-state versions - fun convertBooleanPrefToTriState(key: String): Int { - val oldPrefValue = prefs.getBoolean(key, false) - return if (oldPrefValue) { - 1 - } else { - 0 - } - } - prefs.edit { - putInt( - libraryPreferences.filterDownloaded().key(), - convertBooleanPrefToTriState("pref_filter_downloaded_key"), - ) - remove("pref_filter_downloaded_key") - - putInt( - libraryPreferences.filterUnread().key(), - convertBooleanPrefToTriState("pref_filter_unread_key"), - ) - remove("pref_filter_unread_key") - - putInt( - libraryPreferences.filterCompleted().key(), - convertBooleanPrefToTriState("pref_filter_completed_key"), - ) - remove("pref_filter_completed_key") - } - } - if (oldVersion < 54) { - // Force MAL log out due to login flow change - // v52: switched from scraping to WebView - // v53: switched from WebView to OAuth - if (trackerManager.myAnimeList.isLoggedIn) { - trackerManager.myAnimeList.logout() - context.toast(MR.strings.myanimelist_relogin) - } - } - if (oldVersion < 57) { - // Migrate DNS over HTTPS setting - val wasDohEnabled = prefs.getBoolean("enable_doh", false) - if (wasDohEnabled) { - prefs.edit { - putInt(networkPreferences.dohProvider().key(), PREF_DOH_CLOUDFLARE) - remove("enable_doh") - } - } - } - if (oldVersion < 59) { - // Reset rotation to Free after replacing Lock - if (prefs.contains("pref_rotation_type_key")) { - prefs.edit { - putInt("pref_rotation_type_key", 1) - } - } - } - if (oldVersion < 60) { - // Migrate Rotation and Viewer values to default values for viewer_flags - val newOrientation = when (prefs.getInt("pref_rotation_type_key", 1)) { - 1 -> ReaderOrientation.FREE.flagValue - 2 -> ReaderOrientation.PORTRAIT.flagValue - 3 -> ReaderOrientation.LANDSCAPE.flagValue - 4 -> ReaderOrientation.LOCKED_PORTRAIT.flagValue - 5 -> ReaderOrientation.LOCKED_LANDSCAPE.flagValue - else -> ReaderOrientation.FREE.flagValue - } - - // Reading mode flag and prefValue is the same value - val newReadingMode = prefs.getInt("pref_default_viewer_key", 1) - - prefs.edit { - putInt("pref_default_orientation_type_key", newOrientation) - remove("pref_rotation_type_key") - putInt("pref_default_reading_mode_key", newReadingMode) - remove("pref_default_viewer_key") - } - } - if (oldVersion < 61) { - // Handle removed every 1 or 2 hour library updates - val updateInterval = libraryPreferences.autoUpdateInterval().get() - if (updateInterval == 1 || updateInterval == 2) { - libraryPreferences.autoUpdateInterval().set(3) - LibraryUpdateJob.setupTask(context, 3) - } - } - if (oldVersion < 64) { - val oldSortingMode = prefs.getInt(libraryPreferences.sortingMode().key(), 0) - val oldSortingDirection = prefs.getBoolean("library_sorting_ascending", true) - - @Suppress("DEPRECATION") - val newSortingMode = when (oldSortingMode) { - 0 -> "ALPHABETICAL" - 1 -> "LAST_READ" - 2 -> "LAST_CHECKED" - 3 -> "UNREAD" - 4 -> "TOTAL_CHAPTERS" - 6 -> "LATEST_CHAPTER" - 8 -> "DATE_FETCHED" - 7 -> "DATE_ADDED" - else -> "ALPHABETICAL" - } - - val newSortingDirection = when (oldSortingDirection) { - true -> "ASCENDING" - else -> "DESCENDING" - } - - prefs.edit(commit = true) { - remove(libraryPreferences.sortingMode().key()) - remove("library_sorting_ascending") - } - - prefs.edit { - putString(libraryPreferences.sortingMode().key(), newSortingMode) - putString("library_sorting_ascending", newSortingDirection) - } - } - if (oldVersion < 70) { - if (sourcePreferences.enabledLanguages().isSet()) { - sourcePreferences.enabledLanguages() += "all" - } - } - if (oldVersion < 71) { - // Handle removed every 3, 4, 6, and 8 hour library updates - val updateInterval = libraryPreferences.autoUpdateInterval().get() - if (updateInterval in listOf(3, 4, 6, 8)) { - libraryPreferences.autoUpdateInterval().set(12) - LibraryUpdateJob.setupTask(context, 12) - } - } - if (oldVersion < 72) { - val oldUpdateOngoingOnly = prefs.getBoolean("pref_update_only_non_completed_key", true) - if (!oldUpdateOngoingOnly) { - libraryPreferences.autoUpdateMangaRestrictions() -= MANGA_NON_COMPLETED - } - } - if (oldVersion < 75) { - val oldSecureScreen = prefs.getBoolean("secure_screen", false) - if (oldSecureScreen) { - securityPreferences.secureScreen().set(SecurityPreferences.SecureScreenMode.ALWAYS) - } - if ( - DeviceUtil.isMiui && - basePreferences.extensionInstaller().get() == BasePreferences.ExtensionInstaller.PACKAGEINSTALLER - ) { - basePreferences.extensionInstaller().set(BasePreferences.ExtensionInstaller.LEGACY) - } - } - if (oldVersion < 77) { - val oldReaderTap = prefs.getBoolean("reader_tap", false) - if (!oldReaderTap) { - readerPreferences.navigationModePager().set(5) - readerPreferences.navigationModeWebtoon().set(5) - } - } - if (oldVersion < 81) { - // Handle renamed enum values - prefs.edit { - val newSortingMode = when ( - val oldSortingMode = prefs.getString( - libraryPreferences.sortingMode().key(), - "ALPHABETICAL", - ) - ) { - "LAST_CHECKED" -> "LAST_MANGA_UPDATE" - "UNREAD" -> "UNREAD_COUNT" - "DATE_FETCHED" -> "CHAPTER_FETCH_DATE" - else -> oldSortingMode - } - putString(libraryPreferences.sortingMode().key(), newSortingMode) - } - } - if (oldVersion < 82) { - prefs.edit { - val sort = prefs.getString(libraryPreferences.sortingMode().key(), null) ?: return@edit - val direction = prefs.getString("library_sorting_ascending", "ASCENDING")!! - putString(libraryPreferences.sortingMode().key(), "$sort,$direction") - remove("library_sorting_ascending") - } - } - if (oldVersion < 84) { - if (backupPreferences.backupInterval().get() == 0) { - backupPreferences.backupInterval().set(12) - BackupCreateJob.setupTask(context) - } - } - if (oldVersion < 85) { - val preferences = listOf( - libraryPreferences.filterChapterByRead(), - libraryPreferences.filterChapterByDownloaded(), - libraryPreferences.filterChapterByBookmarked(), - libraryPreferences.sortChapterBySourceOrNumber(), - libraryPreferences.displayChapterByNameOrNumber(), - libraryPreferences.sortChapterByAscendingOrDescending(), - ) - - prefs.edit { - preferences.forEach { preference -> - val key = preference.key() - val value = prefs.getInt(key, Int.MIN_VALUE) - if (value == Int.MIN_VALUE) return@forEach - remove(key) - putLong(key, value.toLong()) - } - } - } - if (oldVersion < 86) { - if (uiPreferences.themeMode().isSet()) { - prefs.edit { - val themeMode = prefs.getString(uiPreferences.themeMode().key(), null) ?: return@edit - putString(uiPreferences.themeMode().key(), themeMode.uppercase()) - } - } - } - if (oldVersion < 92) { - val trackingQueuePref = context.getSharedPreferences("tracking_queue", Context.MODE_PRIVATE) - trackingQueuePref.all.forEach { - val (_, lastChapterRead) = it.value.toString().split(":") - trackingQueuePref.edit { - remove(it.key) - putFloat(it.key, lastChapterRead.toFloat()) - } - } - } - if (oldVersion < 96) { - LibraryUpdateJob.cancelAllWorks(context) - LibraryUpdateJob.setupTask(context) - } - if (oldVersion < 97) { - // Removed background jobs - context.workManager.cancelAllWorkByTag("UpdateChecker") - context.workManager.cancelAllWorkByTag("ExtensionUpdate") - prefs.edit { - remove("automatic_ext_updates") - } - } - if (oldVersion < 99) { - val prefKeys = listOf( - "pref_filter_library_downloaded", - "pref_filter_library_unread", - "pref_filter_library_started", - "pref_filter_library_bookmarked", - "pref_filter_library_completed", - ) + trackerManager.trackers.map { "pref_filter_library_tracked_${it.id}" } - - prefKeys.forEach { key -> - val pref = preferenceStore.getInt(key, 0) - prefs.edit { - remove(key) - - val newValue = when (pref.get()) { - 1 -> TriState.ENABLED_IS - 2 -> TriState.ENABLED_NOT - else -> TriState.DISABLED - } - - preferenceStore.getEnum("${key}_v2", TriState.DISABLED).set(newValue) - } - } - } - if (oldVersion < 105) { - val pref = libraryPreferences.autoUpdateDeviceRestrictions() - if (pref.isSet() && "battery_not_low" in pref.get()) { - pref.getAndSet { it - "battery_not_low" } - } - } - if (oldVersion < 106) { - val pref = preferenceStore.getInt("relative_time", 7) - if (pref.get() == 0) { - uiPreferences.relativeTime().set(false) - } - } - if (oldVersion < 113) { - val prefsToReplace = listOf( - "pref_download_only", - "incognito_mode", - "last_catalogue_source", - "trusted_signatures", - "last_app_closed", - "library_update_last_timestamp", - "library_unseen_updates_count", - "last_used_category", - "last_app_check", - "last_ext_check", - "last_version_code", - "storage_dir", - ) - replacePreferences( - preferenceStore = preferenceStore, - filterPredicate = { it.key in prefsToReplace }, - newKey = { Preference.appStateKey(it) }, - ) - - // Deleting old download cache index files, but might as well clear it all out - context.cacheDir.deleteRecursively() - } - if (oldVersion < 114) { - sourcePreferences.extensionRepos().getAndSet { - it.map { repo -> "https://raw.githubusercontent.com/$repo/repo" }.toSet() - } - } - if (oldVersion < 116) { - replacePreferences( - preferenceStore = preferenceStore, - filterPredicate = { it.key.startsWith("pref_mangasync_") || it.key.startsWith("track_token_") }, - newKey = { Preference.privateKey(it) }, - ) - } - if (oldVersion < 117) { - prefs.edit { - remove(Preference.appStateKey("trusted_signatures")) - } - } - return true } return false } } - -@Suppress("UNCHECKED_CAST") -private fun replacePreferences( - preferenceStore: PreferenceStore, - filterPredicate: (Map.Entry) -> Boolean, - newKey: (String) -> String, -) { - preferenceStore.getAll() - .filter(filterPredicate) - .forEach { (key, value) -> - when (value) { - is Int -> { - preferenceStore.getInt(newKey(key)).set(value) - preferenceStore.getInt(key).delete() - } - is Long -> { - preferenceStore.getLong(newKey(key)).set(value) - preferenceStore.getLong(key).delete() - } - is Float -> { - preferenceStore.getFloat(newKey(key)).set(value) - preferenceStore.getFloat(key).delete() - } - is String -> { - preferenceStore.getString(newKey(key)).set(value) - preferenceStore.getString(key).delete() - } - is Boolean -> { - preferenceStore.getBoolean(newKey(key)).set(value) - preferenceStore.getBoolean(key).delete() - } - is Set<*> -> (value as? Set)?.let { - preferenceStore.getStringSet(newKey(key)).set(value) - preferenceStore.getStringSet(key).delete() - } - } - } -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt index f684f1f1c..c6473d16a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt @@ -1,7 +1,6 @@ package eu.kanade.tachiyomi.extension.api import android.content.Context -import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.LoadResult @@ -11,9 +10,14 @@ import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.parseAs import exh.source.BlacklistedSources +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import logcat.LogPriority +import mihon.domain.extensionrepo.interactor.GetExtensionRepo +import mihon.domain.extensionrepo.interactor.UpdateExtensionRepo +import mihon.domain.extensionrepo.model.ExtensionRepo import tachiyomi.core.common.preference.Preference import tachiyomi.core.common.preference.PreferenceStore import tachiyomi.core.common.util.lang.withIOContext @@ -26,7 +30,8 @@ internal class ExtensionApi { private val networkService: NetworkHelper 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 json: Json by injectLazy() @@ -36,11 +41,15 @@ internal class ExtensionApi { suspend fun findExtensions(): List { return withIOContext { - sourcePreferences.extensionRepos().get().flatMap { getExtensions(it) } + getExtensionRepo.getAll() + .map { async { getExtensions(it) } } + .awaitAll() + .flatten() } } - private suspend fun getExtensions(repoBaseUrl: String): List { + private suspend fun getExtensions(extRepo: ExtensionRepo): List { + val repoBaseUrl = extRepo.baseUrl return try { val response = networkService.client .newCall(GET("$repoBaseUrl/index.min.json")) @@ -68,6 +77,9 @@ internal class ExtensionApi { return null } + // Update extension repo details + updateExtensionRepo.awaitAll() + val extensions = if (fromAvailableExtensionList) { extensionManager.availableExtensionsFlow.value } else { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 89d34c337..d4d79a6a7 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -178,6 +178,7 @@ class MainActivity : BaseActivity() { backupPreferences = Injekt.get(), trackerManager = Injekt.get(), pagePreviewCache = Injekt.get(), + extensionRepoRepository = Injekt.get(), ) } else { false diff --git a/app/src/main/java/exh/EXHMigrations.kt b/app/src/main/java/exh/EXHMigrations.kt index ffbee0bfd..ef6b7b04c 100644 --- a/app/src/main/java/exh/EXHMigrations.kt +++ b/app/src/main/java/exh/EXHMigrations.kt @@ -43,6 +43,8 @@ import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonPrimitive 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.PreferenceStore import tachiyomi.core.common.preference.TriState @@ -105,6 +107,7 @@ object EXHMigrations { backupPreferences: BackupPreferences, trackerManager: TrackerManager, pagePreviewCache: PagePreviewCache, + extensionRepoRepository: ExtensionRepoRepository, ): Boolean { val lastVersionCode = preferenceStore.getInt("eh_last_version_code", 0) val oldVersion = lastVersionCode.get() @@ -686,6 +689,25 @@ object EXHMigrations { ) } + if (oldVersion under 67) { + runBlocking { + 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() + } + } + // if (oldVersion under 1) { } (1 is current release version) // do stuff here when releasing changed crap diff --git a/data/src/main/java/mihon/data/repository/ExtensionRepoRepositoryImpl.kt b/data/src/main/java/mihon/data/repository/ExtensionRepoRepositoryImpl.kt new file mode 100644 index 000000000..65fcd149c --- /dev/null +++ b/data/src/main/java/mihon/data/repository/ExtensionRepoRepositoryImpl.kt @@ -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> { + return handler.subscribeToList { extension_reposQueries.findAll(::mapExtensionRepo) } + } + + override suspend fun getAll(): List { + 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 { + 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, + ) +} diff --git a/data/src/main/sqldelight/tachiyomi/data/extension_repos.sq b/data/src/main/sqldelight/tachiyomi/data/extension_repos.sq new file mode 100644 index 000000000..6db69132a --- /dev/null +++ b/data/src/main/sqldelight/tachiyomi/data/extension_repos.sq @@ -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; diff --git a/data/src/main/sqldelight/tachiyomi/migrations/32.sqm b/data/src/main/sqldelight/tachiyomi/migrations/32.sqm new file mode 100644 index 000000000..ecf3b16a8 --- /dev/null +++ b/data/src/main/sqldelight/tachiyomi/migrations/32.sqm @@ -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 +); diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 1740f3898..c6100a38b 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -37,6 +37,7 @@ tasks { withType { kotlinOptions.freeCompilerArgs += listOf( "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-Xcontext-receivers", ) } } diff --git a/domain/src/main/java/mihon/domain/extensionrepo/exception/SaveExtensionRepoException.kt b/domain/src/main/java/mihon/domain/extensionrepo/exception/SaveExtensionRepoException.kt new file mode 100644 index 000000000..4c6990be0 --- /dev/null +++ b/domain/src/main/java/mihon/domain/extensionrepo/exception/SaveExtensionRepoException.kt @@ -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) diff --git a/domain/src/main/java/mihon/domain/extensionrepo/interactor/CreateExtensionRepo.kt b/domain/src/main/java/mihon/domain/extensionrepo/interactor/CreateExtensionRepo.kt new file mode 100644 index 000000000..6285d4915 --- /dev/null +++ b/domain/src/main/java/mihon/domain/extensionrepo/interactor/CreateExtensionRepo.kt @@ -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 + } +} diff --git a/domain/src/main/java/mihon/domain/extensionrepo/interactor/DeleteExtensionRepo.kt b/domain/src/main/java/mihon/domain/extensionrepo/interactor/DeleteExtensionRepo.kt new file mode 100644 index 000000000..3be5c1ad9 --- /dev/null +++ b/domain/src/main/java/mihon/domain/extensionrepo/interactor/DeleteExtensionRepo.kt @@ -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) + } +} diff --git a/domain/src/main/java/mihon/domain/extensionrepo/interactor/GetExtensionRepo.kt b/domain/src/main/java/mihon/domain/extensionrepo/interactor/GetExtensionRepo.kt new file mode 100644 index 000000000..e85bd6c01 --- /dev/null +++ b/domain/src/main/java/mihon/domain/extensionrepo/interactor/GetExtensionRepo.kt @@ -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> = extensionRepoRepository.subscribeAll() + + suspend fun getAll(): List = extensionRepoRepository.getAll() +} diff --git a/domain/src/main/java/mihon/domain/extensionrepo/interactor/GetExtensionRepoCount.kt b/domain/src/main/java/mihon/domain/extensionrepo/interactor/GetExtensionRepoCount.kt new file mode 100644 index 000000000..6ca59f10b --- /dev/null +++ b/domain/src/main/java/mihon/domain/extensionrepo/interactor/GetExtensionRepoCount.kt @@ -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() +} diff --git a/domain/src/main/java/mihon/domain/extensionrepo/interactor/ReplaceExtensionRepo.kt b/domain/src/main/java/mihon/domain/extensionrepo/interactor/ReplaceExtensionRepo.kt new file mode 100644 index 000000000..6543b8924 --- /dev/null +++ b/domain/src/main/java/mihon/domain/extensionrepo/interactor/ReplaceExtensionRepo.kt @@ -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) + } +} diff --git a/domain/src/main/java/mihon/domain/extensionrepo/interactor/UpdateExtensionRepo.kt b/domain/src/main/java/mihon/domain/extensionrepo/interactor/UpdateExtensionRepo.kt new file mode 100644 index 000000000..90e49307e --- /dev/null +++ b/domain/src/main/java/mihon/domain/extensionrepo/interactor/UpdateExtensionRepo.kt @@ -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) + } + } +} diff --git a/domain/src/main/java/mihon/domain/extensionrepo/model/ExtensionRepo.kt b/domain/src/main/java/mihon/domain/extensionrepo/model/ExtensionRepo.kt new file mode 100644 index 000000000..ec9ccca87 --- /dev/null +++ b/domain/src/main/java/mihon/domain/extensionrepo/model/ExtensionRepo.kt @@ -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, +) diff --git a/domain/src/main/java/mihon/domain/extensionrepo/repository/ExtensionRepoRepository.kt b/domain/src/main/java/mihon/domain/extensionrepo/repository/ExtensionRepoRepository.kt new file mode 100644 index 000000000..8551254be --- /dev/null +++ b/domain/src/main/java/mihon/domain/extensionrepo/repository/ExtensionRepoRepository.kt @@ -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> + + suspend fun getAll(): List + + suspend fun getRepository(baseUrl: String): ExtensionRepo? + + suspend fun getRepositoryBySigningKeyFingerprint(fingerprint: String): ExtensionRepo? + + fun getCount(): Flow + + 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) +} diff --git a/domain/src/main/java/mihon/domain/extensionrepo/service/ExtensionRepoService.kt b/domain/src/main/java/mihon/domain/extensionrepo/service/ExtensionRepoService.kt new file mode 100644 index 000000000..ca061304e --- /dev/null +++ b/domain/src/main/java/mihon/domain/extensionrepo/service/ExtensionRepoService.kt @@ -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() + } + 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 + } + } +} diff --git a/i18n/src/commonMain/resources/MR/base/strings.xml b/i18n/src/commonMain/resources/MR/base/strings.xml index 39cfa134c..5d7559751 100755 --- a/i18n/src/commonMain/resources/MR/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/base/strings.xml @@ -353,6 +353,9 @@ Invalid repo URL Do you wish to delete the repo \"%s\"? Open source repo + Replace + Signing Key Fingerprint Already Exists + 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. Fullscreen