From c17ada2c98041877ab901efb9b03497130ead34a Mon Sep 17 00:00:00 2001 From: arkon Date: Fri, 5 Jan 2024 17:28:08 -0500 Subject: [PATCH] Support external repos Largely taken from SY. Co-authored-by: jobobby04 --- .../java/eu/kanade/domain/DomainModule.kt | 7 ++ .../source/interactor/CreateSourceRepo.kt | 34 ++++++ .../source/interactor/DeleteSourceRepos.kt | 12 ++ .../source/interactor/GetSourceRepos.kt | 12 ++ .../source/service/SourcePreferences.kt | 2 + .../browse/ExtensionDetailsScreen.kt | 18 ++- .../presentation/category/SourceRepoScreen.kt | 60 ++++++++++ .../category/components/CategoryDialogs.kt | 76 ++++++------ .../components/repo/SourceRepoContent.kt | 79 ++++++++++++ .../presentation/category/repos/RepoScreen.kt | 75 ++++++++++++ .../category/repos/RepoScreenModel.kt | 112 ++++++++++++++++++ .../settings/screen/SettingsBrowseScreen.kt | 17 +++ .../extension/api/ExtensionGithubApi.kt | 39 ++++-- .../tachiyomi/extension/model/Extension.kt | 4 + .../source/browse/BrowseSourceScreenModel.kt | 9 +- .../tachiyomi/ui/category/CategoryScreen.kt | 14 ++- .../ui/library/LibraryScreenModel.kt | 21 ++-- .../tachiyomi/ui/manga/MangaScreenModel.kt | 9 +- .../commonMain/resources/MR/base/plurals.xml | 5 + .../commonMain/resources/MR/base/strings.xml | 11 ++ 20 files changed, 557 insertions(+), 59 deletions(-) create mode 100644 app/src/main/java/eu/kanade/domain/source/interactor/CreateSourceRepo.kt create mode 100644 app/src/main/java/eu/kanade/domain/source/interactor/DeleteSourceRepos.kt create mode 100644 app/src/main/java/eu/kanade/domain/source/interactor/GetSourceRepos.kt create mode 100644 app/src/main/java/eu/kanade/presentation/category/SourceRepoScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/category/components/repo/SourceRepoContent.kt create mode 100644 app/src/main/java/eu/kanade/presentation/category/repos/RepoScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/category/repos/RepoScreenModel.kt diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 778b7645c7..6ef3a44467 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -11,8 +11,11 @@ import eu.kanade.domain.manga.interactor.GetExcludedScanlators import eu.kanade.domain.manga.interactor.SetExcludedScanlators import eu.kanade.domain.manga.interactor.SetMangaViewerFlags import eu.kanade.domain.manga.interactor.UpdateManga +import eu.kanade.domain.source.interactor.CreateSourceRepo +import eu.kanade.domain.source.interactor.DeleteSourceRepos import eu.kanade.domain.source.interactor.GetEnabledSources import eu.kanade.domain.source.interactor.GetLanguagesWithSources +import eu.kanade.domain.source.interactor.GetSourceRepos import eu.kanade.domain.source.interactor.GetSourcesWithFavoriteCount import eu.kanade.domain.source.interactor.SetMigrateSorting import eu.kanade.domain.source.interactor.ToggleLanguage @@ -167,5 +170,9 @@ class DomainModule : InjektModule { addFactory { ToggleLanguage(get()) } addFactory { ToggleSource(get()) } addFactory { ToggleSourcePin(get()) } + + addFactory { CreateSourceRepo(get()) } + addFactory { DeleteSourceRepos(get()) } + addFactory { GetSourceRepos(get()) } } } diff --git a/app/src/main/java/eu/kanade/domain/source/interactor/CreateSourceRepo.kt b/app/src/main/java/eu/kanade/domain/source/interactor/CreateSourceRepo.kt new file mode 100644 index 0000000000..1140b4eb92 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/source/interactor/CreateSourceRepo.kt @@ -0,0 +1,34 @@ +package eu.kanade.domain.source.interactor + +import eu.kanade.domain.source.service.SourcePreferences +import tachiyomi.core.preference.plusAssign + +class CreateSourceRepo(private val preferences: SourcePreferences) { + + fun await(name: String): Result { + // Do not allow invalid formats + if (!name.matches(repoRegex)) { + return Result.InvalidName + } + + preferences.extensionRepos() += name + + return Result.Success + } + + sealed class Result { + data object InvalidName : Result() + data object Success : Result() + } + + /** + * Returns true if a repo with the given name already exists. + */ + private fun repoExists(name: String): Boolean { + return preferences.extensionRepos().get().any { it.equals(name, true) } + } + + companion object { + val repoRegex = """^[a-zA-Z0-9-_.]*?\/[a-zA-Z0-9-_.]*?$""".toRegex() + } +} diff --git a/app/src/main/java/eu/kanade/domain/source/interactor/DeleteSourceRepos.kt b/app/src/main/java/eu/kanade/domain/source/interactor/DeleteSourceRepos.kt new file mode 100644 index 0000000000..e8cd4721aa --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/source/interactor/DeleteSourceRepos.kt @@ -0,0 +1,12 @@ +package eu.kanade.domain.source.interactor + +import eu.kanade.domain.source.service.SourcePreferences + +class DeleteSourceRepos(private val preferences: SourcePreferences) { + + fun await(repos: List) { + preferences.extensionRepos().set( + preferences.extensionRepos().get().filterNot { it in repos }.toSet(), + ) + } +} diff --git a/app/src/main/java/eu/kanade/domain/source/interactor/GetSourceRepos.kt b/app/src/main/java/eu/kanade/domain/source/interactor/GetSourceRepos.kt new file mode 100644 index 0000000000..25e3b3a21d --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/source/interactor/GetSourceRepos.kt @@ -0,0 +1,12 @@ +package eu.kanade.domain.source.interactor + +import eu.kanade.domain.source.service.SourcePreferences +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class GetSourceRepos(private val preferences: SourcePreferences) { + + fun subscribe(): Flow> { + return preferences.extensionRepos().changes().map { it.sortedWith(String.CASE_INSENSITIVE_ORDER) } + } +} diff --git a/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt b/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt index 0fe4ce23fc..ea00bfc693 100644 --- a/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt +++ b/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt @@ -38,6 +38,8 @@ class SourcePreferences( SetMigrateSorting.Direction.ASCENDING, ) + fun extensionRepos() = preferenceStore.getStringSet("extension_repos", emptySet()) + fun extensionUpdatesCount() = preferenceStore.getInt("ext_updates_count", 0) fun trustedSignatures() = preferenceStore.getStringSet(Preference.appStateKey("trusted_signatures"), emptySet()) diff --git a/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt index 5ab0159d7b..3c4b8e2ca3 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -116,7 +117,7 @@ fun ExtensionDetailsScreen( ) { paddingValues -> if (state.extension == null) { EmptyScreen( - stringRes = MR.strings.empty_screen, + MR.strings.empty_screen, modifier = Modifier.padding(paddingValues), ) return@Scaffold @@ -149,6 +150,21 @@ private fun ExtensionDetails( contentPadding = contentPadding, ) { when { + extension.isRepoSource -> + item { + val uriHandler = LocalUriHandler.current + WarningBanner( + MR.strings.repo_extension_message, + modifier = Modifier.clickable { + extension.repoUrl ?: return@clickable + uriHandler.openUri( + extension.repoUrl + .replace("https://raw.githubusercontent.com", "https://github.com") + .removeSuffix("/repo/"), + ) + }, + ) + } extension.isUnofficial -> item { WarningBanner(MR.strings.unofficial_extension_message) diff --git a/app/src/main/java/eu/kanade/presentation/category/SourceRepoScreen.kt b/app/src/main/java/eu/kanade/presentation/category/SourceRepoScreen.kt new file mode 100644 index 0000000000..780ad0f0a5 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/category/SourceRepoScreen.kt @@ -0,0 +1,60 @@ +package eu.kanade.presentation.category + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.rememberLazyListState +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.category.components.repo.SourceRepoContent +import eu.kanade.presentation.category.repos.RepoScreenState +import eu.kanade.presentation.components.AppBar +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.components.material.Scaffold +import tachiyomi.presentation.core.components.material.padding +import tachiyomi.presentation.core.components.material.topSmallPaddingValues +import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.screens.EmptyScreen +import tachiyomi.presentation.core.util.plus + +@Composable +fun SourceRepoScreen( + state: RepoScreenState.Success, + onClickCreate: () -> Unit, + onClickDelete: (String) -> Unit, + navigateUp: () -> Unit, +) { + val lazyListState = rememberLazyListState() + Scaffold( + topBar = { scrollBehavior -> + AppBar( + navigateUp = navigateUp, + title = stringResource(MR.strings.label_extension_repos), + scrollBehavior = scrollBehavior, + ) + }, + floatingActionButton = { + CategoryFloatingActionButton( + lazyListState = lazyListState, + onCreate = onClickCreate, + ) + }, + ) { paddingValues -> + if (state.isEmpty) { + EmptyScreen( + MR.strings.information_empty_repos, + modifier = Modifier.padding(paddingValues), + ) + return@Scaffold + } + + SourceRepoContent( + repos = state.repos, + lazyListState = lazyListState, + paddingValues = paddingValues + topSmallPaddingValues + + PaddingValues(horizontal = MaterialTheme.padding.medium), + onClickDelete = onClickDelete, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/category/components/CategoryDialogs.kt b/app/src/main/java/eu/kanade/presentation/category/components/CategoryDialogs.kt index d7a484c6de..676a5b1954 100644 --- a/app/src/main/java/eu/kanade/presentation/category/components/CategoryDialogs.kt +++ b/app/src/main/java/eu/kanade/presentation/category/components/CategoryDialogs.kt @@ -25,9 +25,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import dev.icerock.moko.resources.StringResource import eu.kanade.core.preference.asToggleableState import eu.kanade.presentation.category.visualName import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay import tachiyomi.core.preference.CheckboxState import tachiyomi.domain.category.model.Category @@ -40,12 +42,15 @@ import kotlin.time.Duration.Companion.seconds fun CategoryCreateDialog( onDismissRequest: () -> Unit, onCreate: (String) -> Unit, - categories: ImmutableList, + categories: ImmutableList, + title: String, + extraMessage: String? = null, + alreadyExistsError: StringResource = MR.strings.error_category_exists, ) { var name by remember { mutableStateOf("") } val focusRequester = remember { FocusRequester() } - val nameAlreadyExists = remember(name) { categories.anyWithName(name) } + val nameAlreadyExists = remember(name) { categories.contains(name) } AlertDialog( onDismissRequest = onDismissRequest, @@ -66,25 +71,32 @@ fun CategoryCreateDialog( } }, title = { - Text(text = stringResource(MR.strings.action_add_category)) + Text(text = title) }, text = { - OutlinedTextField( - modifier = Modifier.focusRequester(focusRequester), - value = name, - onValueChange = { name = it }, - label = { Text(text = stringResource(MR.strings.name)) }, - supportingText = { - val msgRes = if (name.isNotEmpty() && nameAlreadyExists) { - MR.strings.error_category_exists - } else { - MR.strings.information_required_plain - } - Text(text = stringResource(msgRes)) - }, - isError = name.isNotEmpty() && nameAlreadyExists, - singleLine = true, - ) + Column { + extraMessage?.let { Text(it) } + + OutlinedTextField( + modifier = Modifier + .focusRequester(focusRequester), + value = name, + onValueChange = { name = it }, + label = { + Text(text = stringResource(MR.strings.name)) + }, + supportingText = { + val msgRes = if (name.isNotEmpty() && nameAlreadyExists) { + alreadyExistsError + } else { + MR.strings.information_required_plain + } + Text(text = stringResource(msgRes)) + }, + isError = name.isNotEmpty() && nameAlreadyExists, + singleLine = true, + ) + } }, ) @@ -99,14 +111,15 @@ fun CategoryCreateDialog( fun CategoryRenameDialog( onDismissRequest: () -> Unit, onRename: (String) -> Unit, - categories: ImmutableList, - category: Category, + categories: ImmutableList, + category: String, + alreadyExistsError: StringResource = MR.strings.error_category_exists, ) { - var name by remember { mutableStateOf(category.name) } + var name by remember { mutableStateOf(category) } var valueHasChanged by remember { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } - val nameAlreadyExists = remember(name) { categories.anyWithName(name) } + val nameAlreadyExists = remember(name) { categories.contains(name) } AlertDialog( onDismissRequest = onDismissRequest, @@ -140,7 +153,7 @@ fun CategoryRenameDialog( label = { Text(text = stringResource(MR.strings.name)) }, supportingText = { val msgRes = if (valueHasChanged && nameAlreadyExists) { - MR.strings.error_category_exists + alreadyExistsError } else { MR.strings.information_required_plain } @@ -163,7 +176,8 @@ fun CategoryRenameDialog( fun CategoryDeleteDialog( onDismissRequest: () -> Unit, onDelete: () -> Unit, - category: Category, + title: String, + text: String, ) { AlertDialog( onDismissRequest = onDismissRequest, @@ -181,10 +195,10 @@ fun CategoryDeleteDialog( } }, title = { - Text(text = stringResource(MR.strings.delete_category)) + Text(text = title) }, text = { - Text(text = stringResource(MR.strings.delete_category_confirmation, category.name)) + Text(text = text) }, ) } @@ -220,7 +234,7 @@ fun CategorySortAlphabeticallyDialog( @Composable fun ChangeCategoryDialog( - initialSelection: List>, + initialSelection: ImmutableList>, onDismissRequest: () -> Unit, onEditCategories: () -> Unit, onConfirm: (List, List) -> Unit, @@ -292,7 +306,7 @@ fun ChangeCategoryDialog( if (index != -1) { val mutableList = selection.toMutableList() mutableList[index] = it.next() - selection = mutableList.toList() + selection = mutableList.toList().toImmutableList() } } Row( @@ -326,7 +340,3 @@ fun ChangeCategoryDialog( }, ) } - -private fun List.anyWithName(name: String): Boolean { - return any { name == it.name } -} diff --git a/app/src/main/java/eu/kanade/presentation/category/components/repo/SourceRepoContent.kt b/app/src/main/java/eu/kanade/presentation/category/components/repo/SourceRepoContent.kt new file mode 100644 index 0000000000..b3cda3fb18 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/category/components/repo/SourceRepoContent.kt @@ -0,0 +1,79 @@ +package eu.kanade.presentation.category.components.repo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Label +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import kotlinx.collections.immutable.ImmutableList +import tachiyomi.presentation.core.components.material.padding + +@Composable +fun SourceRepoContent( + repos: ImmutableList, + lazyListState: LazyListState, + paddingValues: PaddingValues, + onClickDelete: (String) -> Unit, + modifier: Modifier = Modifier, +) { + LazyColumn( + state = lazyListState, + contentPadding = paddingValues, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), + modifier = modifier, + ) { + items(repos) { repo -> + SourceRepoListItem( + modifier = Modifier.animateItemPlacement(), + repo = repo, + onDelete = { onClickDelete(repo) }, + ) + } + } +} + +@Composable +private fun SourceRepoListItem( + repo: String, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + ElevatedCard( + modifier = modifier, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + start = MaterialTheme.padding.medium, + top = MaterialTheme.padding.medium, + end = MaterialTheme.padding.medium, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = "") + Text(text = repo, modifier = Modifier.padding(start = MaterialTheme.padding.medium)) + } + Row { + Spacer(modifier = Modifier.weight(1f)) + IconButton(onClick = onDelete) { + Icon(imageVector = Icons.Outlined.Delete, contentDescription = "") + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/category/repos/RepoScreen.kt b/app/src/main/java/eu/kanade/presentation/category/repos/RepoScreen.kt new file mode 100644 index 0000000000..7a0d089e66 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/category/repos/RepoScreen.kt @@ -0,0 +1,75 @@ +package eu.kanade.presentation.category.repos + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalContext +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.presentation.category.SourceRepoScreen +import eu.kanade.presentation.category.components.CategoryCreateDialog +import eu.kanade.presentation.category.components.CategoryDeleteDialog +import eu.kanade.presentation.util.Screen +import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.flow.collectLatest +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.screens.LoadingScreen + +class RepoScreen : Screen() { + + @Composable + override fun Content() { + val context = LocalContext.current + val navigator = LocalNavigator.currentOrThrow + val screenModel = rememberScreenModel { RepoScreenModel() } + + val state by screenModel.state.collectAsState() + + if (state is RepoScreenState.Loading) { + LoadingScreen() + return + } + + val successState = state as RepoScreenState.Success + + SourceRepoScreen( + state = successState, + onClickCreate = { screenModel.showDialog(RepoDialog.Create) }, + onClickDelete = { screenModel.showDialog(RepoDialog.Delete(it)) }, + navigateUp = navigator::pop, + ) + + when (val dialog = successState.dialog) { + null -> {} + RepoDialog.Create -> { + CategoryCreateDialog( + onDismissRequest = screenModel::dismissDialog, + onCreate = { screenModel.createRepo(it) }, + categories = successState.repos, + title = stringResource(MR.strings.action_add_repo), + extraMessage = stringResource(MR.strings.action_add_repo_message), + alreadyExistsError = MR.strings.error_repo_exists, + ) + } + is RepoDialog.Delete -> { + CategoryDeleteDialog( + onDismissRequest = screenModel::dismissDialog, + onDelete = { screenModel.deleteRepos(listOf(dialog.repo)) }, + title = stringResource(MR.strings.action_delete_repo), + text = stringResource(MR.strings.delete_repo_confirmation, dialog.repo), + ) + } + } + + LaunchedEffect(Unit) { + screenModel.events.collectLatest { event -> + if (event is RepoEvent.LocalizedMessage) { + context.toast(event.stringRes) + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/category/repos/RepoScreenModel.kt b/app/src/main/java/eu/kanade/presentation/category/repos/RepoScreenModel.kt new file mode 100644 index 0000000000..039990b200 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/category/repos/RepoScreenModel.kt @@ -0,0 +1,112 @@ +package eu.kanade.presentation.category.repos + +import androidx.compose.runtime.Immutable +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import dev.icerock.moko.resources.StringResource +import eu.kanade.domain.source.interactor.CreateSourceRepo +import eu.kanade.domain.source.interactor.DeleteSourceRepos +import eu.kanade.domain.source.interactor.GetSourceRepos +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import tachiyomi.core.util.lang.launchIO +import tachiyomi.i18n.MR +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class RepoScreenModel( + private val getSourceRepos: GetSourceRepos = Injekt.get(), + private val createSourceRepo: CreateSourceRepo = Injekt.get(), + private val deleteSourceRepos: DeleteSourceRepos = Injekt.get(), +) : StateScreenModel(RepoScreenState.Loading) { + + private val _events: Channel = Channel(Int.MAX_VALUE) + val events = _events.receiveAsFlow() + + init { + screenModelScope.launchIO { + getSourceRepos.subscribe() + .collectLatest { repos -> + mutableState.update { + RepoScreenState.Success( + repos = repos.toImmutableList(), + ) + } + } + } + } + + /** + * Creates and adds a new repo to the database. + * + * @param name The name of the repo to create. + */ + fun createRepo(name: String) { + screenModelScope.launchIO { + when (createSourceRepo.await(name)) { + is CreateSourceRepo.Result.InvalidName -> _events.send(RepoEvent.InvalidName) + else -> {} + } + } + } + + /** + * Deletes the given repos from the database. + * + * @param repos The list of repos to delete. + */ + fun deleteRepos(repos: List) { + screenModelScope.launchIO { + deleteSourceRepos.await(repos) + } + } + + fun showDialog(dialog: RepoDialog) { + mutableState.update { + when (it) { + RepoScreenState.Loading -> it + is RepoScreenState.Success -> it.copy(dialog = dialog) + } + } + } + + fun dismissDialog() { + mutableState.update { + when (it) { + RepoScreenState.Loading -> it + is RepoScreenState.Success -> it.copy(dialog = null) + } + } + } +} + +sealed class RepoEvent { + sealed class LocalizedMessage(val stringRes: StringResource) : RepoEvent() + data object InvalidName : LocalizedMessage(MR.strings.invalid_repo_name) + data object InternalError : LocalizedMessage(MR.strings.internal_error) +} + +sealed class RepoDialog { + data object Create : RepoDialog() + data class Delete(val repo: String) : RepoDialog() +} + +sealed class RepoScreenState { + + @Immutable + data object Loading : RepoScreenState() + + @Immutable + data class Success( + val repos: ImmutableList, + val dialog: RepoDialog? = null, + ) : RepoScreenState() { + + val isEmpty: Boolean + get() = repos.isEmpty() + } +} 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 61c1db21e7..9fb4670758 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,16 +2,22 @@ package eu.kanade.presentation.more.settings.screen import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.fragment.app.FragmentActivity +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.presentation.category.repos.RepoScreen import eu.kanade.presentation.more.settings.Preference import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate import kotlinx.collections.immutable.persistentListOf import tachiyomi.core.i18n.stringResource import tachiyomi.i18n.MR +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 @@ -24,7 +30,11 @@ object SettingsBrowseScreen : SearchableSettings { @Composable override fun getPreferences(): List { val context = LocalContext.current + val navigator = LocalNavigator.currentOrThrow + val sourcePreferences = remember { Injekt.get() } + val reposCount by sourcePreferences.extensionRepos().collectAsState() + return listOf( Preference.PreferenceGroup( title = stringResource(MR.strings.label_sources), @@ -33,6 +43,13 @@ object SettingsBrowseScreen : SearchableSettings { pref = sourcePreferences.hideInLibraryItems(), title = stringResource(MR.strings.pref_hide_in_library_items), ), + Preference.PreferenceItem.TextPreference( + title = stringResource(MR.strings.label_extension_repos), + subtitle = pluralStringResource(MR.plurals.num_repos, reposCount.size, reposCount.size), + onClick = { + navigator.push(RepoScreen()) + }, + ), ), ), Preference.PreferenceGroup( diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt index 3032aee03e..184f4ef452 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt @@ -1,6 +1,7 @@ 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 @@ -24,6 +25,7 @@ internal class ExtensionGithubApi { private val networkService: NetworkHelper by injectLazy() private val preferenceStore: PreferenceStore by injectLazy() + private val sourcePreferences: SourcePreferences by injectLazy() private val extensionManager: ExtensionManager by injectLazy() private val json: Json by injectLazy() @@ -58,7 +60,20 @@ internal class ExtensionGithubApi { val extensions = with(json) { response .parseAs>() - .toExtensions() + .toExtensions() + sourcePreferences.extensionRepos() + .get() + .flatMap { repoPath -> + val url = if (requiresFallbackSource) { + "$FALLBACK_BASE_URL$repoPath@repo/" + } else { + "$BASE_URL$repoPath/repo/" + } + networkService.client + .newCall(GET("${url}index.min.json")) + .awaitSuccess() + .parseAs>() + .toExtensions(url, repoSource = true) + } } // Sanity check - a small number of extensions probably means something broke @@ -71,10 +86,7 @@ internal class ExtensionGithubApi { } } - suspend fun checkForUpdates( - context: Context, - fromAvailableExtensionList: Boolean = false, - ): List? { + suspend fun checkForUpdates(context: Context, fromAvailableExtensionList: Boolean = false): List? { // Limit checks to once a day at most if (!fromAvailableExtensionList && Instant.now().toEpochMilli() < lastExtCheck.get() + 1.days.inWholeMilliseconds @@ -111,7 +123,10 @@ internal class ExtensionGithubApi { return extensionsWithUpdate } - private fun List.toExtensions(): List { + private fun List.toExtensions( + repoUrl: String = getUrlPrefix(), + repoSource: Boolean = false, + ): List { return this .filter { val libVersion = it.extractLibVersion() @@ -128,13 +143,15 @@ internal class ExtensionGithubApi { isNsfw = it.nsfw == 1, sources = it.sources?.map(extensionSourceMapper).orEmpty(), apkName = it.apk, - iconUrl = "${getUrlPrefix()}icon/${it.pkg}.png", + iconUrl = "${repoUrl}icon/${it.pkg}.png", + repoUrl = repoUrl, + isRepoSource = repoSource, ) } } fun getApkUrl(extension: Extension.Available): String { - return "${getUrlPrefix()}apk/${extension.apkName}" + return "${extension.repoUrl}/apk/${extension.apkName}" } private fun getUrlPrefix(): String { @@ -150,8 +167,10 @@ internal class ExtensionGithubApi { } } -private const val REPO_URL_PREFIX = "https://raw.githubusercontent.com/tachiyomiorg/tachiyomi-extensions/repo/" -private const val FALLBACK_REPO_URL_PREFIX = "https://gcore.jsdelivr.net/gh/tachiyomiorg/tachiyomi-extensions@repo/" +private const val BASE_URL = "https://raw.githubusercontent.com/" +private const val REPO_URL_PREFIX = "${BASE_URL}tachiyomiorg/tachiyomi-extensions/repo/" +private const val FALLBACK_BASE_URL = "https://gcore.jsdelivr.net/gh/" +private const val FALLBACK_REPO_URL_PREFIX = "${FALLBACK_BASE_URL}tachiyomiorg/tachiyomi-extensions@repo/" @Serializable private data class ExtensionJsonObject( diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt index 7f4a316aa6..4dbf09a265 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt @@ -29,6 +29,8 @@ sealed class Extension { val isObsolete: Boolean = false, val isUnofficial: Boolean = false, val isShared: Boolean, + val repoUrl: String? = null, + val isRepoSource: Boolean = false, ) : Extension() data class Available( @@ -42,6 +44,8 @@ sealed class Extension { val sources: List, val apkName: String, val iconUrl: String, + val repoUrl: String, + val isRepoSource: Boolean, ) : Extension() { data class Source( diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt index 4d0c49404c..4b08358504 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt @@ -24,6 +24,8 @@ import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.util.removeCovers +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow @@ -265,7 +267,10 @@ class BrowseSourceScreenModel( else -> { val preselectedIds = getCategories.await(manga.id).map { it.id } setDialog( - Dialog.ChangeMangaCategory(manga, categories.mapAsCheckboxState { it.id in preselectedIds }), + Dialog.ChangeMangaCategory( + manga, + categories.mapAsCheckboxState { it.id in preselectedIds }.toImmutableList(), + ), ) } } @@ -338,7 +343,7 @@ class BrowseSourceScreenModel( data class AddDuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog data class ChangeMangaCategory( val manga: Manga, - val initialSelection: List>, + val initialSelection: ImmutableList>, ) : Dialog data class Migrate(val newManga: Manga) : Dialog } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryScreen.kt index bc9fbd4a5e..dcd0246bb2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.util.fastMap import cafe.adriel.voyager.core.model.rememberScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow @@ -15,7 +16,10 @@ import eu.kanade.presentation.category.components.CategoryRenameDialog import eu.kanade.presentation.category.components.CategorySortAlphabeticallyDialog import eu.kanade.presentation.util.Screen import eu.kanade.tachiyomi.util.system.toast +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.collectLatest +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.screens.LoadingScreen class CategoryScreen : Screen() { @@ -52,22 +56,24 @@ class CategoryScreen : Screen() { CategoryCreateDialog( onDismissRequest = screenModel::dismissDialog, onCreate = screenModel::createCategory, - categories = successState.categories, + categories = successState.categories.fastMap { it.name }.toImmutableList(), + title = stringResource(MR.strings.action_add_category), ) } is CategoryDialog.Rename -> { CategoryRenameDialog( onDismissRequest = screenModel::dismissDialog, onRename = { screenModel.renameCategory(dialog.category, it) }, - categories = successState.categories, - category = dialog.category, + categories = successState.categories.fastMap { it.name }.toImmutableList(), + category = dialog.category.name, ) } is CategoryDialog.Delete -> { CategoryDeleteDialog( onDismissRequest = screenModel::dismissDialog, onDelete = { screenModel.deleteCategory(dialog.category.id) }, - category = dialog.category, + title = stringResource(MR.strings.delete_category), + text = stringResource(MR.strings.delete_category_confirmation, dialog.category.name), ) } is CategoryDialog.SortAlphabetically -> { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt index a19753a841..105f1e2b32 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt @@ -28,9 +28,11 @@ import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.util.chapter.getNextUnread import eu.kanade.tachiyomi.util.removeCovers +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.mutate import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine @@ -661,13 +663,15 @@ class LibraryScreenModel( val common = getCommonCategories(mangaList) // Get indexes of the mix categories to preselect. val mix = getMixCategories(mangaList) - val preselected = categories.map { - when (it) { - in common -> CheckboxState.State.Checked(it) - in mix -> CheckboxState.TriState.Exclude(it) - else -> CheckboxState.State.None(it) + val preselected = categories + .map { + when (it) { + in common -> CheckboxState.State.Checked(it) + in mix -> CheckboxState.TriState.Exclude(it) + else -> CheckboxState.State.None(it) + } } - } + .toImmutableList() mutableState.update { it.copy(dialog = Dialog.ChangeCategory(mangaList, preselected)) } } } @@ -683,7 +687,10 @@ class LibraryScreenModel( sealed interface Dialog { data object SettingsSheet : Dialog - data class ChangeCategory(val manga: List, val initialSelection: List>) : Dialog + data class ChangeCategory( + val manga: List, + val initialSelection: ImmutableList>, + ) : Dialog data class DeleteManga(val manga: List) : Dialog } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt index aa85aec5a4..e7446e600c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt @@ -36,6 +36,8 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import eu.kanade.tachiyomi.util.chapter.getNextUnread import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.shouldDownloadNewChapters +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.catch @@ -360,7 +362,7 @@ class MangaScreenModel( successState.copy( dialog = Dialog.ChangeCategory( manga = manga, - initialSelection = categories.mapAsCheckboxState { it.id in selection }, + initialSelection = categories.mapAsCheckboxState { it.id in selection }.toImmutableList(), ), ) } @@ -992,7 +994,10 @@ class MangaScreenModel( // Track sheet - end sealed interface Dialog { - data class ChangeCategory(val manga: Manga, val initialSelection: List>) : Dialog + data class ChangeCategory( + val manga: Manga, + val initialSelection: ImmutableList>, + ) : Dialog data class DeleteChapters(val chapters: List) : Dialog data class DuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog data class SetFetchInterval(val manga: Manga) : Dialog diff --git a/i18n/src/commonMain/resources/MR/base/plurals.xml b/i18n/src/commonMain/resources/MR/base/plurals.xml index 21b5446182..2f10b004c7 100644 --- a/i18n/src/commonMain/resources/MR/base/plurals.xml +++ b/i18n/src/commonMain/resources/MR/base/plurals.xml @@ -80,4 +80,9 @@ Extension update available %d extension updates available + + + %d repo + %d repos + diff --git a/i18n/src/commonMain/resources/MR/base/strings.xml b/i18n/src/commonMain/resources/MR/base/strings.xml index df8d826a61..a724bac5e2 100644 --- a/i18n/src/commonMain/resources/MR/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/base/strings.xml @@ -336,6 +336,17 @@ Shizuku is not running Install and start Shizuku to use Shizuku as extension installer. + + Extension repos + You have no repos set. + Add repo + Add additional repos to Tachiyomi, the format of a repo is \"username/repo\", with username being the repo owner, and repo being the repo name. + This repo already exists! + Delete repo + Invalid repo name + Do you wish to delete the repo \"%s\"? + This extension is from an external repo. Tap to view the repo. + Fullscreen Show tap zones overlay