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
This commit is contained in:
arkon 2024-01-05 23:13:16 -05:00
parent 556f5a42a7
commit 9c899e97a9
20 changed files with 252 additions and 183 deletions

View File

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

View File

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

View File

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

View File

@ -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
}
}

View File

@ -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(),
)
}
}

View File

@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.map
class GetSourceRepos(private val preferences: SourcePreferences) { class GetSourceRepos(private val preferences: SourcePreferences) {
fun subscribe(): Flow<List<String>> { 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) }
} }
} }

View File

@ -25,7 +25,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.core.preference.asToggleableState
import eu.kanade.presentation.category.visualName import eu.kanade.presentation.category.visualName
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -43,9 +42,6 @@ fun CategoryCreateDialog(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onCreate: (String) -> Unit, onCreate: (String) -> Unit,
categories: ImmutableList<String>, categories: ImmutableList<String>,
title: String,
extraMessage: String? = null,
alreadyExistsError: StringResource = MR.strings.error_category_exists,
) { ) {
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
@ -71,32 +67,28 @@ fun CategoryCreateDialog(
} }
}, },
title = { title = {
Text(text = title) Text(text = stringResource(MR.strings.action_add_category))
}, },
text = { text = {
Column { OutlinedTextField(
extraMessage?.let { Text(it) } modifier = Modifier
.focusRequester(focusRequester),
OutlinedTextField( value = name,
modifier = Modifier onValueChange = { name = it },
.focusRequester(focusRequester), label = {
value = name, Text(text = stringResource(MR.strings.name))
onValueChange = { name = it }, },
label = { supportingText = {
Text(text = stringResource(MR.strings.name)) val msgRes = if (name.isNotEmpty() && nameAlreadyExists) {
}, MR.strings.error_category_exists
supportingText = { } else {
val msgRes = if (name.isNotEmpty() && nameAlreadyExists) { MR.strings.information_required_plain
alreadyExistsError }
} else { Text(text = stringResource(msgRes))
MR.strings.information_required_plain },
} isError = name.isNotEmpty() && nameAlreadyExists,
Text(text = stringResource(msgRes)) singleLine = true,
}, )
isError = name.isNotEmpty() && nameAlreadyExists,
singleLine = true,
)
}
}, },
) )
@ -113,7 +105,6 @@ fun CategoryRenameDialog(
onRename: (String) -> Unit, onRename: (String) -> Unit,
categories: ImmutableList<String>, categories: ImmutableList<String>,
category: String, category: String,
alreadyExistsError: StringResource = MR.strings.error_category_exists,
) { ) {
var name by remember { mutableStateOf(category) } var name by remember { mutableStateOf(category) }
var valueHasChanged by remember { mutableStateOf(false) } var valueHasChanged by remember { mutableStateOf(false) }
@ -153,7 +144,7 @@ fun CategoryRenameDialog(
label = { Text(text = stringResource(MR.strings.name)) }, label = { Text(text = stringResource(MR.strings.name)) },
supportingText = { supportingText = {
val msgRes = if (valueHasChanged && nameAlreadyExists) { val msgRes = if (valueHasChanged && nameAlreadyExists) {
alreadyExistsError MR.strings.error_category_exists
} else { } else {
MR.strings.information_required_plain MR.strings.information_required_plain
} }
@ -176,8 +167,7 @@ fun CategoryRenameDialog(
fun CategoryDeleteDialog( fun CategoryDeleteDialog(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
title: String, category: String,
text: String,
) { ) {
AlertDialog( AlertDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
@ -195,10 +185,10 @@ fun CategoryDeleteDialog(
} }
}, },
title = { title = {
Text(text = title) Text(text = stringResource(MR.strings.delete_category))
}, },
text = { text = {
Text(text = text) Text(text = stringResource(MR.strings.delete_category_confirmation, category))
}, },
) )
} }

View File

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

View File

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

View File

@ -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.Composable
import androidx.compose.runtime.LaunchedEffect 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.core.model.rememberScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.category.SourceRepoScreen import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoCreateDialog
import eu.kanade.presentation.category.components.CategoryCreateDialog import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionRepoDeleteDialog
import eu.kanade.presentation.category.components.CategoryDeleteDialog import eu.kanade.presentation.more.settings.screen.browse.components.ExtensionReposScreen
import eu.kanade.presentation.util.Screen import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.LoadingScreen import tachiyomi.presentation.core.screens.LoadingScreen
class RepoScreen : Screen() { class ExtensionReposScreen : Screen() {
@Composable @Composable
override fun Content() { override fun Content() {
val context = LocalContext.current val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val screenModel = rememberScreenModel { RepoScreenModel() } val screenModel = rememberScreenModel { ExtensionReposScreenModel() }
val state by screenModel.state.collectAsState() val state by screenModel.state.collectAsState()
@ -35,7 +33,7 @@ class RepoScreen : Screen() {
val successState = state as RepoScreenState.Success val successState = state as RepoScreenState.Success
SourceRepoScreen( ExtensionReposScreen(
state = successState, state = successState,
onClickCreate = { screenModel.showDialog(RepoDialog.Create) }, onClickCreate = { screenModel.showDialog(RepoDialog.Create) },
onClickDelete = { screenModel.showDialog(RepoDialog.Delete(it)) }, onClickDelete = { screenModel.showDialog(RepoDialog.Delete(it)) },
@ -45,21 +43,17 @@ class RepoScreen : Screen() {
when (val dialog = successState.dialog) { when (val dialog = successState.dialog) {
null -> {} null -> {}
RepoDialog.Create -> { RepoDialog.Create -> {
CategoryCreateDialog( ExtensionRepoCreateDialog(
onDismissRequest = screenModel::dismissDialog, onDismissRequest = screenModel::dismissDialog,
onCreate = { screenModel.createRepo(it) }, onCreate = { screenModel.createRepo(it) },
categories = successState.repos, 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 -> { is RepoDialog.Delete -> {
CategoryDeleteDialog( ExtensionRepoDeleteDialog(
onDismissRequest = screenModel::dismissDialog, onDismissRequest = screenModel::dismissDialog,
onDelete = { screenModel.deleteRepos(listOf(dialog.repo)) }, onDelete = { screenModel.deleteRepo(dialog.repo) },
title = stringResource(MR.strings.action_delete_repo), repo = dialog.repo,
text = stringResource(MR.strings.delete_repo_confirmation, dialog.repo),
) )
} }
} }

View File

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

View File

@ -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.Arrangement
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@ -24,7 +23,7 @@ import kotlinx.collections.immutable.ImmutableList
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
@Composable @Composable
fun SourceRepoContent( fun ExtensionReposContent(
repos: ImmutableList<String>, repos: ImmutableList<String>,
lazyListState: LazyListState, lazyListState: LazyListState,
paddingValues: PaddingValues, paddingValues: PaddingValues,
@ -38,7 +37,7 @@ fun SourceRepoContent(
modifier = modifier, modifier = modifier,
) { ) {
items(repos) { repo -> items(repos) { repo ->
SourceRepoListItem( ExtensionRepoListItem(
modifier = Modifier.animateItemPlacement(), modifier = Modifier.animateItemPlacement(),
repo = repo, repo = repo,
onDelete = { onClickDelete(repo) }, onDelete = { onClickDelete(repo) },
@ -48,7 +47,7 @@ fun SourceRepoContent(
} }
@Composable @Composable
private fun SourceRepoListItem( private fun ExtensionRepoListItem(
repo: String, repo: String,
onDelete: () -> Unit, onDelete: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -66,13 +65,16 @@ private fun SourceRepoListItem(
), ),
verticalAlignment = Alignment.CenterVertically, 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)) 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) { IconButton(onClick = onDelete) {
Icon(imageVector = Icons.Outlined.Delete, contentDescription = "") Icon(imageVector = Icons.Outlined.Delete, contentDescription = null)
} }
} }
} }

View File

@ -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))
},
)
}

View File

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

View File

@ -405,6 +405,11 @@ object Migrations {
// Deleting old download cache index files, but might as well clear it all out // Deleting old download cache index files, but might as well clear it all out
context.cacheDir.deleteRecursively() context.cacheDir.deleteRecursively()
} }
if (oldVersion < 114) {
sourcePreferences.extensionRepos().getAndSet {
it.map { "https://raw.githubusercontent.com/$it/repo" }.toSet()
}
}
return true return true
} }

View File

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

View File

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

View File

@ -18,8 +18,6 @@ import eu.kanade.presentation.util.Screen
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.screens.LoadingScreen import tachiyomi.presentation.core.screens.LoadingScreen
class CategoryScreen : Screen() { class CategoryScreen : Screen() {
@ -57,7 +55,6 @@ class CategoryScreen : Screen() {
onDismissRequest = screenModel::dismissDialog, onDismissRequest = screenModel::dismissDialog,
onCreate = screenModel::createCategory, onCreate = screenModel::createCategory,
categories = successState.categories.fastMap { it.name }.toImmutableList(), categories = successState.categories.fastMap { it.name }.toImmutableList(),
title = stringResource(MR.strings.action_add_category),
) )
} }
is CategoryDialog.Rename -> { is CategoryDialog.Rename -> {
@ -72,8 +69,7 @@ class CategoryScreen : Screen() {
CategoryDeleteDialog( CategoryDeleteDialog(
onDismissRequest = screenModel::dismissDialog, onDismissRequest = screenModel::dismissDialog,
onDelete = { screenModel.deleteCategory(dialog.category.id) }, onDelete = { screenModel.deleteCategory(dialog.category.id) },
title = stringResource(MR.strings.delete_category), category = dialog.category.name,
text = stringResource(MR.strings.delete_category_confirmation, dialog.category.name),
) )
} }
is CategoryDialog.SortAlphabetically -> { is CategoryDialog.SortAlphabetically -> {

View File

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

View File

@ -340,10 +340,11 @@
<string name="label_extension_repos">Extension repos</string> <string name="label_extension_repos">Extension repos</string>
<string name="information_empty_repos">You have no repos set.</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">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="error_repo_exists">This repo already exists!</string>
<string name="action_delete_repo">Delete repo</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="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> <string name="repo_extension_message">This extension is from an external repo. Tap to view the repo.</string>