瀏覽代碼

Clean up external repos

- Accept full URL as input instead, which allows for non-GitHub
- Remove automatic CDN fallback in favor of adding that as an external repo if needed
arkon 1 年之前
父節點
當前提交
9c899e97a9
共有 20 個文件被更改,包括 252 次插入183 次删除
  1. 1 1
      app/build.gradle.kts
  2. 2 2
      app/src/main/java/eu/kanade/domain/DomainModule.kt
  3. 9 17
      app/src/main/java/eu/kanade/domain/source/interactor/CreateSourceRepo.kt
  4. 11 0
      app/src/main/java/eu/kanade/domain/source/interactor/DeleteSourceRepo.kt
  5. 0 12
      app/src/main/java/eu/kanade/domain/source/interactor/DeleteSourceRepos.kt
  6. 2 1
      app/src/main/java/eu/kanade/domain/source/interactor/GetSourceRepos.kt
  7. 24 34
      app/src/main/java/eu/kanade/presentation/category/components/CategoryDialogs.kt
  8. 3 3
      app/src/main/java/eu/kanade/presentation/category/components/CategoryListItem.kt
  9. 2 2
      app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt
  10. 11 17
      app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreen.kt
  11. 10 11
      app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreenModel.kt
  12. 11 9
      app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposContent.kt
  13. 117 0
      app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposDialogs.kt
  14. 6 5
      app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposScreen.kt
  15. 5 0
      app/src/main/java/eu/kanade/tachiyomi/Migrations.kt
  16. 2 2
      app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt
  17. 30 58
      app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt
  18. 1 5
      app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryScreen.kt
  19. 2 2
      app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt
  20. 3 2
      i18n/src/commonMain/resources/MR/base/strings.xml

+ 1 - 1
app/build.gradle.kts

@@ -22,7 +22,7 @@ android {
     defaultConfig {
         applicationId = "eu.kanade.tachiyomi"
 
-        versionCode = 113
+        versionCode = 114
         versionName = "0.14.7"
 
         buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")

+ 2 - 2
app/src/main/java/eu/kanade/domain/DomainModule.kt

@@ -12,7 +12,7 @@ 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.DeleteSourceRepo
 import eu.kanade.domain.source.interactor.GetEnabledSources
 import eu.kanade.domain.source.interactor.GetLanguagesWithSources
 import eu.kanade.domain.source.interactor.GetSourceRepos
@@ -172,7 +172,7 @@ class DomainModule : InjektModule {
         addFactory { ToggleSourcePin(get()) }
 
         addFactory { CreateSourceRepo(get()) }
-        addFactory { DeleteSourceRepos(get()) }
+        addFactory { DeleteSourceRepo(get()) }
         addFactory { GetSourceRepos(get()) }
     }
 }

+ 9 - 17
app/src/main/java/eu/kanade/domain/source/interactor/CreateSourceRepo.kt

@@ -7,28 +7,20 @@ class CreateSourceRepo(private val preferences: SourcePreferences) {
 
     fun await(name: String): Result {
         // Do not allow invalid formats
-        if (!name.matches(repoRegex)) {
-            return Result.InvalidName
+        if (!name.matches(repoRegex) || name.startsWith(OFFICIAL_REPO_BASE_URL)) {
+            return Result.InvalidUrl
         }
 
-        preferences.extensionRepos() += name
+        preferences.extensionRepos() += name.substringBeforeLast("/index.min.json")
 
         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()
+    sealed interface Result {
+        data object InvalidUrl : Result
+        data object Success : Result
     }
 }
+
+const val OFFICIAL_REPO_BASE_URL = "https://raw.githubusercontent.com/tachiyomiorg/tachiyomi-extensions/repo"
+private val repoRegex = """^https://.*/index\.min\.json$""".toRegex()

+ 11 - 0
app/src/main/java/eu/kanade/domain/source/interactor/DeleteSourceRepo.kt

@@ -0,0 +1,11 @@
+package eu.kanade.domain.source.interactor
+
+import eu.kanade.domain.source.service.SourcePreferences
+import tachiyomi.core.preference.minusAssign
+
+class DeleteSourceRepo(private val preferences: SourcePreferences) {
+
+    fun await(repo: String) {
+        preferences.extensionRepos() -= repo
+    }
+}

+ 0 - 12
app/src/main/java/eu/kanade/domain/source/interactor/DeleteSourceRepos.kt

@@ -1,12 +0,0 @@
-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(),
-        )
-    }
-}

+ 2 - 1
app/src/main/java/eu/kanade/domain/source/interactor/GetSourceRepos.kt

@@ -7,6 +7,7 @@ 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) }
+        return preferences.extensionRepos().changes()
+            .map { it.sortedWith(String.CASE_INSENSITIVE_ORDER) }
     }
 }

+ 24 - 34
app/src/main/java/eu/kanade/presentation/category/components/CategoryDialogs.kt

@@ -25,7 +25,6 @@ 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
@@ -43,9 +42,6 @@ fun CategoryCreateDialog(
     onDismissRequest: () -> Unit,
     onCreate: (String) -> Unit,
     categories: ImmutableList<String>,
-    title: String,
-    extraMessage: String? = null,
-    alreadyExistsError: StringResource = MR.strings.error_category_exists,
 ) {
     var name by remember { mutableStateOf("") }
 
@@ -71,32 +67,28 @@ fun CategoryCreateDialog(
             }
         },
         title = {
-            Text(text = title)
+            Text(text = stringResource(MR.strings.action_add_category))
         },
         text = {
-            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,
-                )
-            }
+            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,
+            )
         },
     )
 
@@ -113,7 +105,6 @@ fun CategoryRenameDialog(
     onRename: (String) -> Unit,
     categories: ImmutableList<String>,
     category: String,
-    alreadyExistsError: StringResource = MR.strings.error_category_exists,
 ) {
     var name by remember { mutableStateOf(category) }
     var valueHasChanged by remember { mutableStateOf(false) }
@@ -153,7 +144,7 @@ fun CategoryRenameDialog(
                 label = { Text(text = stringResource(MR.strings.name)) },
                 supportingText = {
                     val msgRes = if (valueHasChanged && nameAlreadyExists) {
-                        alreadyExistsError
+                        MR.strings.error_category_exists
                     } else {
                         MR.strings.information_required_plain
                     }
@@ -176,8 +167,7 @@ fun CategoryRenameDialog(
 fun CategoryDeleteDialog(
     onDismissRequest: () -> Unit,
     onDelete: () -> Unit,
-    title: String,
-    text: String,
+    category: String,
 ) {
     AlertDialog(
         onDismissRequest = onDismissRequest,
@@ -195,10 +185,10 @@ fun CategoryDeleteDialog(
             }
         },
         title = {
-            Text(text = title)
+            Text(text = stringResource(MR.strings.delete_category))
         },
         text = {
-            Text(text = text)
+            Text(text = stringResource(MR.strings.delete_category_confirmation, category))
         },
     )
 }

+ 3 - 3
app/src/main/java/eu/kanade/presentation/category/components/CategoryListItem.kt

@@ -49,7 +49,7 @@ fun CategoryListItem(
                 ),
             verticalAlignment = Alignment.CenterVertically,
         ) {
-            Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = "")
+            Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null)
             Text(
                 text = category.name,
                 modifier = Modifier
@@ -61,13 +61,13 @@ fun CategoryListItem(
                 onClick = { onMoveUp(category) },
                 enabled = canMoveUp,
             ) {
-                Icon(imageVector = Icons.Outlined.ArrowDropUp, contentDescription = "")
+                Icon(imageVector = Icons.Outlined.ArrowDropUp, contentDescription = null)
             }
             IconButton(
                 onClick = { onMoveDown(category) },
                 enabled = canMoveDown,
             ) {
-                Icon(imageVector = Icons.Outlined.ArrowDropDown, contentDescription = "")
+                Icon(imageVector = Icons.Outlined.ArrowDropDown, contentDescription = null)
             }
             Spacer(modifier = Modifier.weight(1f))
             IconButton(onClick = onRename) {

+ 2 - 2
app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsBrowseScreen.kt

@@ -9,8 +9,8 @@ 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.presentation.more.settings.screen.browse.ExtensionReposScreen
 import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
 import kotlinx.collections.immutable.persistentListOf
 import tachiyomi.core.i18n.stringResource
@@ -47,7 +47,7 @@ object SettingsBrowseScreen : SearchableSettings {
                         title = stringResource(MR.strings.label_extension_repos),
                         subtitle = pluralStringResource(MR.plurals.num_repos, reposCount.size, reposCount.size),
                         onClick = {
-                            navigator.push(RepoScreen())
+                            navigator.push(ExtensionReposScreen())
                         },
                     ),
                 ),

+ 11 - 17
app/src/main/java/eu/kanade/presentation/category/repos/RepoScreen.kt → app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreen.kt

@@ -1,4 +1,4 @@
-package eu.kanade.presentation.category.repos
+package eu.kanade.presentation.more.settings.screen.browse
 
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
@@ -8,23 +8,21 @@ 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.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.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() {
+class ExtensionReposScreen : Screen() {
 
     @Composable
     override fun Content() {
         val context = LocalContext.current
         val navigator = LocalNavigator.currentOrThrow
-        val screenModel = rememberScreenModel { RepoScreenModel() }
+        val screenModel = rememberScreenModel { ExtensionReposScreenModel() }
 
         val state by screenModel.state.collectAsState()
 
@@ -35,7 +33,7 @@ class RepoScreen : Screen() {
 
         val successState = state as RepoScreenState.Success
 
-        SourceRepoScreen(
+        ExtensionReposScreen(
             state = successState,
             onClickCreate = { screenModel.showDialog(RepoDialog.Create) },
             onClickDelete = { screenModel.showDialog(RepoDialog.Delete(it)) },
@@ -45,21 +43,17 @@ class RepoScreen : Screen() {
         when (val dialog = successState.dialog) {
             null -> {}
             RepoDialog.Create -> {
-                CategoryCreateDialog(
+                ExtensionRepoCreateDialog(
                     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(
+                ExtensionRepoDeleteDialog(
                     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),
+                    onDelete = { screenModel.deleteRepo(dialog.repo) },
+                    repo = dialog.repo,
                 )
             }
         }

+ 10 - 11
app/src/main/java/eu/kanade/presentation/category/repos/RepoScreenModel.kt → app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/ExtensionReposScreenModel.kt

@@ -1,11 +1,11 @@
-package eu.kanade.presentation.category.repos
+package eu.kanade.presentation.more.settings.screen.browse
 
 import androidx.compose.runtime.Immutable
 import cafe.adriel.voyager.core.model.StateScreenModel
 import cafe.adriel.voyager.core.model.screenModelScope
 import dev.icerock.moko.resources.StringResource
 import eu.kanade.domain.source.interactor.CreateSourceRepo
-import eu.kanade.domain.source.interactor.DeleteSourceRepos
+import eu.kanade.domain.source.interactor.DeleteSourceRepo
 import eu.kanade.domain.source.interactor.GetSourceRepos
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.toImmutableList
@@ -18,10 +18,10 @@ import tachiyomi.i18n.MR
 import uy.kohesive.injekt.Injekt
 import uy.kohesive.injekt.api.get
 
-class RepoScreenModel(
+class ExtensionReposScreenModel(
     private val getSourceRepos: GetSourceRepos = Injekt.get(),
     private val createSourceRepo: CreateSourceRepo = Injekt.get(),
-    private val deleteSourceRepos: DeleteSourceRepos = Injekt.get(),
+    private val deleteSourceRepo: DeleteSourceRepo = Injekt.get(),
 ) : StateScreenModel<RepoScreenState>(RepoScreenState.Loading) {
 
     private val _events: Channel<RepoEvent> = Channel(Int.MAX_VALUE)
@@ -48,20 +48,20 @@ class RepoScreenModel(
     fun createRepo(name: String) {
         screenModelScope.launchIO {
             when (createSourceRepo.await(name)) {
-                is CreateSourceRepo.Result.InvalidName -> _events.send(RepoEvent.InvalidName)
+                is CreateSourceRepo.Result.InvalidUrl -> _events.send(RepoEvent.InvalidUrl)
                 else -> {}
             }
         }
     }
 
     /**
-     * Deletes the given repos from the database.
+     * Deletes the given repo from the database.
      *
-     * @param repos The list of repos to delete.
+     * @param repo The repo to delete.
      */
-    fun deleteRepos(repos: List<String>) {
+    fun deleteRepo(repo: String) {
         screenModelScope.launchIO {
-            deleteSourceRepos.await(repos)
+            deleteSourceRepo.await(repo)
         }
     }
 
@@ -86,8 +86,7 @@ class RepoScreenModel(
 
 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)
+    data object InvalidUrl : LocalizedMessage(MR.strings.invalid_repo_name)
 }
 
 sealed class RepoDialog {

+ 11 - 9
app/src/main/java/eu/kanade/presentation/category/components/repo/SourceRepoContent.kt → app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposContent.kt

@@ -1,9 +1,8 @@
-package eu.kanade.presentation.category.components.repo
+package eu.kanade.presentation.more.settings.screen.browse.components
 
 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
@@ -24,7 +23,7 @@ import kotlinx.collections.immutable.ImmutableList
 import tachiyomi.presentation.core.components.material.padding
 
 @Composable
-fun SourceRepoContent(
+fun ExtensionReposContent(
     repos: ImmutableList<String>,
     lazyListState: LazyListState,
     paddingValues: PaddingValues,
@@ -38,7 +37,7 @@ fun SourceRepoContent(
         modifier = modifier,
     ) {
         items(repos) { repo ->
-            SourceRepoListItem(
+            ExtensionRepoListItem(
                 modifier = Modifier.animateItemPlacement(),
                 repo = repo,
                 onDelete = { onClickDelete(repo) },
@@ -48,7 +47,7 @@ fun SourceRepoContent(
 }
 
 @Composable
-private fun SourceRepoListItem(
+private fun ExtensionRepoListItem(
     repo: String,
     onDelete: () -> Unit,
     modifier: Modifier = Modifier,
@@ -66,13 +65,16 @@ private fun SourceRepoListItem(
                 ),
             verticalAlignment = Alignment.CenterVertically,
         ) {
-            Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = "")
+            Icon(imageVector = Icons.AutoMirrored.Outlined.Label, contentDescription = null)
             Text(text = repo, modifier = Modifier.padding(start = MaterialTheme.padding.medium))
         }
-        Row {
-            Spacer(modifier = Modifier.weight(1f))
+
+        Row(
+            modifier = Modifier.fillMaxWidth(),
+            horizontalArrangement = Arrangement.End,
+        ) {
             IconButton(onClick = onDelete) {
-                Icon(imageVector = Icons.Outlined.Delete, contentDescription = "")
+                Icon(imageVector = Icons.Outlined.Delete, contentDescription = null)
             }
         }
     }

+ 117 - 0
app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposDialogs.kt

@@ -0,0 +1,117 @@
+package eu.kanade.presentation.more.settings.screen.browse.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.coroutines.delay
+import tachiyomi.i18n.MR
+import tachiyomi.presentation.core.i18n.stringResource
+import kotlin.time.Duration.Companion.seconds
+
+@Composable
+fun ExtensionRepoCreateDialog(
+    onDismissRequest: () -> Unit,
+    onCreate: (String) -> Unit,
+    categories: ImmutableList<String>,
+) {
+    var name by remember { mutableStateOf("") }
+
+    val focusRequester = remember { FocusRequester() }
+    val nameAlreadyExists = remember(name) { categories.contains(name) }
+
+    AlertDialog(
+        onDismissRequest = onDismissRequest,
+        confirmButton = {
+            TextButton(
+                enabled = name.isNotEmpty() && !nameAlreadyExists,
+                onClick = {
+                    onCreate(name)
+                    onDismissRequest()
+                },
+            ) {
+                Text(text = stringResource(MR.strings.action_add))
+            }
+        },
+        dismissButton = {
+            TextButton(onClick = onDismissRequest) {
+                Text(text = stringResource(MR.strings.action_cancel))
+            }
+        },
+        title = {
+            Text(text = stringResource(MR.strings.action_add_repo))
+        },
+        text = {
+            Column {
+                Text(text = stringResource(MR.strings.action_add_repo_message))
+
+                OutlinedTextField(
+                    modifier = Modifier
+                        .focusRequester(focusRequester),
+                    value = name,
+                    onValueChange = { name = it },
+                    label = {
+                        Text(text = stringResource(MR.strings.label_add_repo_input))
+                    },
+                    supportingText = {
+                        val msgRes = if (name.isNotEmpty() && nameAlreadyExists) {
+                            MR.strings.error_repo_exists
+                        } else {
+                            MR.strings.information_required_plain
+                        }
+                        Text(text = stringResource(msgRes))
+                    },
+                    isError = name.isNotEmpty() && nameAlreadyExists,
+                    singleLine = true,
+                )
+            }
+        },
+    )
+
+    LaunchedEffect(focusRequester) {
+        // TODO: https://issuetracker.google.com/issues/204502668
+        delay(0.1.seconds)
+        focusRequester.requestFocus()
+    }
+}
+
+@Composable
+fun ExtensionRepoDeleteDialog(
+    onDismissRequest: () -> Unit,
+    onDelete: () -> Unit,
+    repo: String,
+) {
+    AlertDialog(
+        onDismissRequest = onDismissRequest,
+        confirmButton = {
+            TextButton(onClick = {
+                onDelete()
+                onDismissRequest()
+            }) {
+                Text(text = stringResource(MR.strings.action_ok))
+            }
+        },
+        dismissButton = {
+            TextButton(onClick = onDismissRequest) {
+                Text(text = stringResource(MR.strings.action_cancel))
+            }
+        },
+        title = {
+            Text(text = stringResource(MR.strings.action_delete_repo))
+        },
+        text = {
+            Text(text = stringResource(MR.strings.delete_repo_confirmation, repo))
+        },
+    )
+}

+ 6 - 5
app/src/main/java/eu/kanade/presentation/category/SourceRepoScreen.kt → app/src/main/java/eu/kanade/presentation/more/settings/screen/browse/components/ExtensionReposScreen.kt

@@ -1,4 +1,6 @@
-package eu.kanade.presentation.category
+@file:JvmName("ExtensionReposScreenKt")
+
+package eu.kanade.presentation.more.settings.screen.browse.components
 
 import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.foundation.layout.padding
@@ -7,9 +9,8 @@ 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 eu.kanade.presentation.more.settings.screen.browse.RepoScreenState
 import tachiyomi.i18n.MR
 import tachiyomi.presentation.core.components.material.Scaffold
 import tachiyomi.presentation.core.components.material.padding
@@ -19,7 +20,7 @@ import tachiyomi.presentation.core.screens.EmptyScreen
 import tachiyomi.presentation.core.util.plus
 
 @Composable
-fun SourceRepoScreen(
+fun ExtensionReposScreen(
     state: RepoScreenState.Success,
     onClickCreate: () -> Unit,
     onClickDelete: (String) -> Unit,
@@ -49,7 +50,7 @@ fun SourceRepoScreen(
             return@Scaffold
         }
 
-        SourceRepoContent(
+        ExtensionReposContent(
             repos = state.repos,
             lazyListState = lazyListState,
             paddingValues = paddingValues + topSmallPaddingValues +

+ 5 - 0
app/src/main/java/eu/kanade/tachiyomi/Migrations.kt

@@ -405,6 +405,11 @@ object Migrations {
                 // 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 { "https://raw.githubusercontent.com/$it/repo" }.toSet()
+                }
+            }
             return true
         }
 

+ 2 - 2
app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt

@@ -3,7 +3,7 @@ package eu.kanade.tachiyomi.extension
 import android.content.Context
 import android.graphics.drawable.Drawable
 import eu.kanade.domain.source.service.SourcePreferences
-import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi
+import eu.kanade.tachiyomi.extension.api.ExtensionApi
 import eu.kanade.tachiyomi.extension.api.ExtensionUpdateNotifier
 import eu.kanade.tachiyomi.extension.model.Extension
 import eu.kanade.tachiyomi.extension.model.InstallStep
@@ -49,7 +49,7 @@ class ExtensionManager(
     /**
      * API where all the available extensions can be found.
      */
-    private val api = ExtensionGithubApi()
+    private val api = ExtensionApi()
 
     /**
      * The installer which installs, updates and uninstalls the extensions.

+ 30 - 58
app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt → app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionApi.kt

@@ -1,6 +1,7 @@
 package eu.kanade.tachiyomi.extension.api
 
 import android.content.Context
+import eu.kanade.domain.source.interactor.OFFICIAL_REPO_BASE_URL
 import eu.kanade.domain.source.service.SourcePreferences
 import eu.kanade.tachiyomi.extension.ExtensionManager
 import eu.kanade.tachiyomi.extension.model.Extension
@@ -21,7 +22,7 @@ import uy.kohesive.injekt.injectLazy
 import java.time.Instant
 import kotlin.time.Duration.Companion.days
 
-internal class ExtensionGithubApi {
+internal class ExtensionApi {
 
     private val networkService: NetworkHelper by injectLazy()
     private val preferenceStore: PreferenceStore by injectLazy()
@@ -33,52 +34,16 @@ internal class ExtensionGithubApi {
         preferenceStore.getLong(Preference.appStateKey("last_ext_check"), 0)
     }
 
-    private var requiresFallbackSource = false
-
     suspend fun findExtensions(): List<Extension.Available> {
         return withIOContext {
-            val githubResponse = if (requiresFallbackSource) {
-                null
-            } else {
-                try {
-                    networkService.client
-                        .newCall(GET("${REPO_URL_PREFIX}index.min.json"))
-                        .awaitSuccess()
-                } catch (e: Throwable) {
-                    logcat(LogPriority.ERROR, e) { "Failed to get extensions from GitHub" }
-                    requiresFallbackSource = true
-                    null
-                }
-            }
-
-            val response = githubResponse ?: run {
-                networkService.client
-                    .newCall(GET("${FALLBACK_REPO_URL_PREFIX}index.min.json"))
-                    .awaitSuccess()
-            }
-
-            val extensions = with(json) {
-                response
-                    .parseAs<List<ExtensionJsonObject>>()
-                    .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)
-                    }
+            val extensions = buildList {
+                addAll(getExtensions(OFFICIAL_REPO_BASE_URL, true))
+                sourcePreferences.extensionRepos().get().map { addAll(getExtensions(it, false)) }
             }
 
             // Sanity check - a small number of extensions probably means something broke
             // with the repo generator
-            if (extensions.size < 100) {
+            if (extensions.size < 50) {
                 throw Exception()
             }
 
@@ -86,6 +51,26 @@ internal class ExtensionGithubApi {
         }
     }
 
+    private suspend fun getExtensions(
+        repoBaseUrl: String,
+        isOfficialRepo: Boolean,
+    ): List<Extension.Available> {
+        return try {
+            val response = networkService.client
+                .newCall(GET("$repoBaseUrl/index.min.json"))
+                .awaitSuccess()
+
+            with(json) {
+                response
+                    .parseAs<List<ExtensionJsonObject>>()
+                    .toExtensions(repoBaseUrl, isRepoSource = !isOfficialRepo)
+            }
+        } catch (e: Throwable) {
+            logcat(LogPriority.ERROR, e) { "Failed to get extensions from $repoBaseUrl" }
+            emptyList()
+        }
+    }
+
     suspend fun checkForUpdates(
         context: Context,
         fromAvailableExtensionList: Boolean = false,
@@ -127,8 +112,8 @@ internal class ExtensionGithubApi {
     }
 
     private fun List<ExtensionJsonObject>.toExtensions(
-        repoUrl: String = getUrlPrefix(),
-        repoSource: Boolean = false,
+        repoUrl: String,
+        isRepoSource: Boolean,
     ): List<Extension.Available> {
         return this
             .filter {
@@ -146,9 +131,9 @@ internal class ExtensionGithubApi {
                     isNsfw = it.nsfw == 1,
                     sources = it.sources?.map(extensionSourceMapper).orEmpty(),
                     apkName = it.apk,
-                    iconUrl = "${repoUrl}icon/${it.pkg}.png",
+                    iconUrl = "$repoUrl/icon/${it.pkg}.png",
                     repoUrl = repoUrl,
-                    isRepoSource = repoSource,
+                    isRepoSource = isRepoSource,
                 )
             }
     }
@@ -157,24 +142,11 @@ internal class ExtensionGithubApi {
         return "${extension.repoUrl}/apk/${extension.apkName}"
     }
 
-    private fun getUrlPrefix(): String {
-        return if (requiresFallbackSource) {
-            FALLBACK_REPO_URL_PREFIX
-        } else {
-            REPO_URL_PREFIX
-        }
-    }
-
     private fun ExtensionJsonObject.extractLibVersion(): Double {
         return version.substringBeforeLast('.').toDouble()
     }
 }
 
-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(
     val name: String,

+ 1 - 5
app/src/main/java/eu/kanade/tachiyomi/ui/category/CategoryScreen.kt

@@ -18,8 +18,6 @@ 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() {
@@ -57,7 +55,6 @@ class CategoryScreen : Screen() {
                     onDismissRequest = screenModel::dismissDialog,
                     onCreate = screenModel::createCategory,
                     categories = successState.categories.fastMap { it.name }.toImmutableList(),
-                    title = stringResource(MR.strings.action_add_category),
                 )
             }
             is CategoryDialog.Rename -> {
@@ -72,8 +69,7 @@ class CategoryScreen : Screen() {
                 CategoryDeleteDialog(
                     onDismissRequest = screenModel::dismissDialog,
                     onDelete = { screenModel.deleteCategory(dialog.category.id) },
-                    title = stringResource(MR.strings.delete_category),
-                    text = stringResource(MR.strings.delete_category_confirmation, dialog.category.name),
+                    category = dialog.category.name,
                 )
             }
             is CategoryDialog.SortAlphabetically -> {

+ 2 - 2
app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt

@@ -65,7 +65,7 @@ import eu.kanade.tachiyomi.data.download.DownloadCache
 import eu.kanade.tachiyomi.data.notification.NotificationReceiver
 import eu.kanade.tachiyomi.data.updater.AppUpdateChecker
 import eu.kanade.tachiyomi.data.updater.RELEASE_URL
-import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi
+import eu.kanade.tachiyomi.extension.api.ExtensionApi
 import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
 import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen
 import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchScreen
@@ -337,7 +337,7 @@ class MainActivity : BaseActivity() {
         // Extensions updates
         LaunchedEffect(Unit) {
             try {
-                ExtensionGithubApi().checkForUpdates(context)
+                ExtensionApi().checkForUpdates(context)
             } catch (e: Exception) {
                 logcat(LogPriority.ERROR, e)
             }

+ 3 - 2
i18n/src/commonMain/resources/MR/base/strings.xml

@@ -340,10 +340,11 @@
     <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="label_add_repo_input">Repo URL</string>
+    <string name="action_add_repo_message">Add additional repos to Tachiyomi. This should be a URL that ends with \"index.min.json\".</string>
     <string name="error_repo_exists">This repo already exists!</string>
     <string name="action_delete_repo">Delete repo</string>
-    <string name="invalid_repo_name">Invalid repo name</string>
+    <string name="invalid_repo_name">Invalid repo URL</string>
     <string name="delete_repo_confirmation">Do you wish to delete the repo \"%s\"?</string>
     <string name="repo_extension_message">This extension is from an external repo. Tap to view the repo.</string>