Browse Source

Support external repos

Largely taken from SY.

Co-authored-by: jobobby04 <[email protected]>
arkon 1 year ago
parent
commit
c17ada2c98
20 changed files with 557 additions and 59 deletions
  1. 7 0
      app/src/main/java/eu/kanade/domain/DomainModule.kt
  2. 34 0
      app/src/main/java/eu/kanade/domain/source/interactor/CreateSourceRepo.kt
  3. 12 0
      app/src/main/java/eu/kanade/domain/source/interactor/DeleteSourceRepos.kt
  4. 12 0
      app/src/main/java/eu/kanade/domain/source/interactor/GetSourceRepos.kt
  5. 2 0
      app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt
  6. 17 1
      app/src/main/java/eu/kanade/presentation/browse/ExtensionDetailsScreen.kt
  7. 60 0
      app/src/main/java/eu/kanade/presentation/category/SourceRepoScreen.kt
  8. 43 33
      app/src/main/java/eu/kanade/presentation/category/components/CategoryDialogs.kt
  9. 79 0
      app/src/main/java/eu/kanade/presentation/category/components/repo/SourceRepoContent.kt
  10. 75 0
      app/src/main/java/eu/kanade/presentation/category/repos/RepoScreen.kt
  11. 112 0
      app/src/main/java/eu/kanade/presentation/category/repos/RepoScreenModel.kt
  12. 17 0
      app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt
  13. 29 10
      app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt
  14. 4 0
      app/src/main/java/eu/kanade/tachiyomi/extension/model/Extension.kt
  15. 7 2
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt
  16. 10 4
      app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryScreen.kt
  17. 14 7
      app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt
  18. 7 2
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt
  19. 5 0
      i18n/src/commonMain/resources/MR/base/plurals.xml
  20. 11 0
      i18n/src/commonMain/resources/MR/base/strings.xml

+ 7 - 0
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()) }
     }
 }

+ 34 - 0
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()
+    }
+}

+ 12 - 0
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<String>) {
+        preferences.extensionRepos().set(
+            preferences.extensionRepos().get().filterNot { it in repos }.toSet(),
+        )
+    }
+}

+ 12 - 0
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<List<String>> {
+        return preferences.extensionRepos().changes().map { it.sortedWith(String.CASE_INSENSITIVE_ORDER) }
+    }
+}

+ 2 - 0
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())

+ 17 - 1
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)

+ 60 - 0
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,
+        )
+    }
+}

+ 43 - 33
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<Category>,
+    categories: ImmutableList<String>,
+    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: Category,
+    categories: ImmutableList<String>,
+    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<CheckboxState<Category>>,
+    initialSelection: ImmutableList<CheckboxState<Category>>,
     onDismissRequest: () -> Unit,
     onEditCategories: () -> Unit,
     onConfirm: (List<Long>, List<Long>) -> 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<Category>.anyWithName(name: String): Boolean {
-    return any { name == it.name }
-}

+ 79 - 0
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<String>,
+    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 = "")
+            }
+        }
+    }
+}

+ 75 - 0
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)
+                }
+            }
+        }
+    }
+}

+ 112 - 0
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>(RepoScreenState.Loading) {
+
+    private val _events: Channel<RepoEvent> = 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<String>) {
+        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<String>,
+        val dialog: RepoDialog? = null,
+    ) : RepoScreenState() {
+
+        val isEmpty: Boolean
+            get() = repos.isEmpty()
+    }
+}

+ 17 - 0
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<Preference> {
         val context = LocalContext.current
+        val navigator = LocalNavigator.currentOrThrow
+
         val sourcePreferences = remember { Injekt.get<SourcePreferences>() }
+        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(

+ 29 - 10
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<List<ExtensionJsonObject>>()
-                    .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<List<ExtensionJsonObject>>()
+                            .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<Extension.Installed>? {
+    suspend fun checkForUpdates(context: Context, fromAvailableExtensionList: Boolean = false): List<Extension.Installed>? {
         // 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<ExtensionJsonObject>.toExtensions(): List<Extension.Available> {
+    private fun List<ExtensionJsonObject>.toExtensions(
+        repoUrl: String = getUrlPrefix(),
+        repoSource: Boolean = false,
+    ): List<Extension.Available> {
         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(

+ 4 - 0
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<Source>,
         val apkName: String,
         val iconUrl: String,
+        val repoUrl: String,
+        val isRepoSource: Boolean,
     ) : Extension() {
 
         data class Source(

+ 7 - 2
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<CheckboxState.State<Category>>,
+            val initialSelection: ImmutableList<CheckboxState.State<Category>>,
         ) : Dialog
         data class Migrate(val newManga: Manga) : Dialog
     }

+ 10 - 4
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 -> {

+ 14 - 7
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<Manga>, val initialSelection: List<CheckboxState<Category>>) : Dialog
+        data class ChangeCategory(
+            val manga: List<Manga>,
+            val initialSelection: ImmutableList<CheckboxState<Category>>,
+        ) : Dialog
         data class DeleteManga(val manga: List<Manga>) : Dialog
     }
 

+ 7 - 2
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<CheckboxState<Category>>) : Dialog
+        data class ChangeCategory(
+            val manga: Manga,
+            val initialSelection: ImmutableList<CheckboxState<Category>>,
+        ) : Dialog
         data class DeleteChapters(val chapters: List<Chapter>) : Dialog
         data class DuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog
         data class SetFetchInterval(val manga: Manga) : Dialog

+ 5 - 0
i18n/src/commonMain/resources/MR/base/plurals.xml

@@ -80,4 +80,9 @@
         <item quantity="one">Extension update available</item>
         <item quantity="other">%d extension updates available</item>
     </plurals>
+
+    <plurals name="num_repos">
+        <item quantity="one">%d repo</item>
+        <item quantity="other">%d repos</item>
+    </plurals>
 </resources>

+ 11 - 0
i18n/src/commonMain/resources/MR/base/strings.xml

@@ -336,6 +336,17 @@
     <string name="ext_installer_shizuku_stopped">Shizuku is not running</string>
     <string name="ext_installer_shizuku_unavailable_dialog">Install and start Shizuku to use Shizuku as extension installer.</string>
 
+    <!-- Extension repos -->
+    <string name="label_extension_repos">Extension repos</string>
+    <string name="information_empty_repos">You have no repos set.</string>
+    <string name="action_add_repo">Add repo</string>
+    <string name="action_add_repo_message">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.</string>
+    <string name="error_repo_exists">This repo already exists!</string>
+    <string name="action_delete_repo">Delete repo</string>
+    <string name="invalid_repo_name">Invalid repo name</string>
+    <string name="delete_repo_confirmation">Do you wish to delete the repo \"%s\"?</string>
+    <string name="repo_extension_message">This extension is from an external repo. Tap to view the repo.</string>
+
       <!-- Reader section -->
     <string name="pref_fullscreen">Fullscreen</string>
     <string name="pref_show_navigation_mode">Show tap zones overlay</string>