Support external repos

Largely taken from SY.

Co-authored-by: jobobby04 <jobobby04@users.noreply.github.com>
This commit is contained in:
arkon 2024-01-05 17:28:08 -05:00
parent 32bed9b041
commit c17ada2c98
20 changed files with 557 additions and 59 deletions

View File

@ -11,8 +11,11 @@ import eu.kanade.domain.manga.interactor.GetExcludedScanlators
import eu.kanade.domain.manga.interactor.SetExcludedScanlators 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.DeleteSourceRepos
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.GetSourcesWithFavoriteCount import eu.kanade.domain.source.interactor.GetSourcesWithFavoriteCount
import eu.kanade.domain.source.interactor.SetMigrateSorting import eu.kanade.domain.source.interactor.SetMigrateSorting
import eu.kanade.domain.source.interactor.ToggleLanguage import eu.kanade.domain.source.interactor.ToggleLanguage
@ -167,5 +170,9 @@ class DomainModule : InjektModule {
addFactory { ToggleLanguage(get()) } addFactory { ToggleLanguage(get()) }
addFactory { ToggleSource(get()) } addFactory { ToggleSource(get()) }
addFactory { ToggleSourcePin(get()) } addFactory { ToggleSourcePin(get()) }
addFactory { CreateSourceRepo(get()) }
addFactory { DeleteSourceRepos(get()) }
addFactory { GetSourceRepos(get()) }
} }
} }

View File

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

View File

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

View File

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

View File

@ -38,6 +38,8 @@ class SourcePreferences(
SetMigrateSorting.Direction.ASCENDING, SetMigrateSorting.Direction.ASCENDING,
) )
fun extensionRepos() = preferenceStore.getStringSet("extension_repos", emptySet())
fun extensionUpdatesCount() = preferenceStore.getInt("ext_updates_count", 0) fun extensionUpdatesCount() = preferenceStore.getInt("ext_updates_count", 0)
fun trustedSignatures() = preferenceStore.getStringSet(Preference.appStateKey("trusted_signatures"), emptySet()) fun trustedSignatures() = preferenceStore.getStringSet(Preference.appStateKey("trusted_signatures"), emptySet())

View File

@ -37,6 +37,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -116,7 +117,7 @@ fun ExtensionDetailsScreen(
) { paddingValues -> ) { paddingValues ->
if (state.extension == null) { if (state.extension == null) {
EmptyScreen( EmptyScreen(
stringRes = MR.strings.empty_screen, MR.strings.empty_screen,
modifier = Modifier.padding(paddingValues), modifier = Modifier.padding(paddingValues),
) )
return@Scaffold return@Scaffold
@ -149,6 +150,21 @@ private fun ExtensionDetails(
contentPadding = contentPadding, contentPadding = contentPadding,
) { ) {
when { 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 -> extension.isUnofficial ->
item { item {
WarningBanner(MR.strings.unofficial_extension_message) WarningBanner(MR.strings.unofficial_extension_message)

View File

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

View File

@ -25,9 +25,11 @@ 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
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import tachiyomi.core.preference.CheckboxState import tachiyomi.core.preference.CheckboxState
import tachiyomi.domain.category.model.Category import tachiyomi.domain.category.model.Category
@ -40,12 +42,15 @@ import kotlin.time.Duration.Companion.seconds
fun CategoryCreateDialog( fun CategoryCreateDialog(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onCreate: (String) -> 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("") } var name by remember { mutableStateOf("") }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val nameAlreadyExists = remember(name) { categories.anyWithName(name) } val nameAlreadyExists = remember(name) { categories.contains(name) }
AlertDialog( AlertDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
@ -66,17 +71,23 @@ fun CategoryCreateDialog(
} }
}, },
title = { title = {
Text(text = stringResource(MR.strings.action_add_category)) Text(text = title)
}, },
text = { text = {
Column {
extraMessage?.let { Text(it) }
OutlinedTextField( OutlinedTextField(
modifier = Modifier.focusRequester(focusRequester), modifier = Modifier
.focusRequester(focusRequester),
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
label = { Text(text = stringResource(MR.strings.name)) }, label = {
Text(text = stringResource(MR.strings.name))
},
supportingText = { supportingText = {
val msgRes = if (name.isNotEmpty() && nameAlreadyExists) { val msgRes = if (name.isNotEmpty() && nameAlreadyExists) {
MR.strings.error_category_exists alreadyExistsError
} else { } else {
MR.strings.information_required_plain MR.strings.information_required_plain
} }
@ -85,6 +96,7 @@ fun CategoryCreateDialog(
isError = name.isNotEmpty() && nameAlreadyExists, isError = name.isNotEmpty() && nameAlreadyExists,
singleLine = true, singleLine = true,
) )
}
}, },
) )
@ -99,14 +111,15 @@ fun CategoryCreateDialog(
fun CategoryRenameDialog( fun CategoryRenameDialog(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onRename: (String) -> Unit, onRename: (String) -> Unit,
categories: ImmutableList<Category>, categories: ImmutableList<String>,
category: Category, 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) } var valueHasChanged by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val nameAlreadyExists = remember(name) { categories.anyWithName(name) } val nameAlreadyExists = remember(name) { categories.contains(name) }
AlertDialog( AlertDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
@ -140,7 +153,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) {
MR.strings.error_category_exists alreadyExistsError
} else { } else {
MR.strings.information_required_plain MR.strings.information_required_plain
} }
@ -163,7 +176,8 @@ fun CategoryRenameDialog(
fun CategoryDeleteDialog( fun CategoryDeleteDialog(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
category: Category, title: String,
text: String,
) { ) {
AlertDialog( AlertDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
@ -181,10 +195,10 @@ fun CategoryDeleteDialog(
} }
}, },
title = { title = {
Text(text = stringResource(MR.strings.delete_category)) Text(text = title)
}, },
text = { text = {
Text(text = stringResource(MR.strings.delete_category_confirmation, category.name)) Text(text = text)
}, },
) )
} }
@ -220,7 +234,7 @@ fun CategorySortAlphabeticallyDialog(
@Composable @Composable
fun ChangeCategoryDialog( fun ChangeCategoryDialog(
initialSelection: List<CheckboxState<Category>>, initialSelection: ImmutableList<CheckboxState<Category>>,
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
onEditCategories: () -> Unit, onEditCategories: () -> Unit,
onConfirm: (List<Long>, List<Long>) -> Unit, onConfirm: (List<Long>, List<Long>) -> Unit,
@ -292,7 +306,7 @@ fun ChangeCategoryDialog(
if (index != -1) { if (index != -1) {
val mutableList = selection.toMutableList() val mutableList = selection.toMutableList()
mutableList[index] = it.next() mutableList[index] = it.next()
selection = mutableList.toList() selection = mutableList.toList().toImmutableList()
} }
} }
Row( Row(
@ -326,7 +340,3 @@ fun ChangeCategoryDialog(
}, },
) )
} }
private fun List<Category>.anyWithName(name: String): Boolean {
return any { name == it.name }
}

View File

@ -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 = "")
}
}
}
}

View File

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

View File

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

View File

@ -2,16 +2,22 @@ package eu.kanade.presentation.more.settings.screen
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.fragment.app.FragmentActivity 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.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.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
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.i18n.pluralStringResource
import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.i18n.stringResource
import tachiyomi.presentation.core.util.collectAsState
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
@ -24,7 +30,11 @@ object SettingsBrowseScreen : SearchableSettings {
@Composable @Composable
override fun getPreferences(): List<Preference> { override fun getPreferences(): List<Preference> {
val context = LocalContext.current val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val sourcePreferences = remember { Injekt.get<SourcePreferences>() } val sourcePreferences = remember { Injekt.get<SourcePreferences>() }
val reposCount by sourcePreferences.extensionRepos().collectAsState()
return listOf( return listOf(
Preference.PreferenceGroup( Preference.PreferenceGroup(
title = stringResource(MR.strings.label_sources), title = stringResource(MR.strings.label_sources),
@ -33,6 +43,13 @@ object SettingsBrowseScreen : SearchableSettings {
pref = sourcePreferences.hideInLibraryItems(), pref = sourcePreferences.hideInLibraryItems(),
title = stringResource(MR.strings.pref_hide_in_library_items), 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( Preference.PreferenceGroup(

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.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
import eu.kanade.tachiyomi.extension.model.LoadResult import eu.kanade.tachiyomi.extension.model.LoadResult
@ -24,6 +25,7 @@ internal class ExtensionGithubApi {
private val networkService: NetworkHelper by injectLazy() private val networkService: NetworkHelper by injectLazy()
private val preferenceStore: PreferenceStore by injectLazy() private val preferenceStore: PreferenceStore by injectLazy()
private val sourcePreferences: SourcePreferences by injectLazy()
private val extensionManager: ExtensionManager by injectLazy() private val extensionManager: ExtensionManager by injectLazy()
private val json: Json by injectLazy() private val json: Json by injectLazy()
@ -58,7 +60,20 @@ internal class ExtensionGithubApi {
val extensions = with(json) { val extensions = with(json) {
response response
.parseAs<List<ExtensionJsonObject>>() .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 // Sanity check - a small number of extensions probably means something broke
@ -71,10 +86,7 @@ internal class ExtensionGithubApi {
} }
} }
suspend fun checkForUpdates( suspend fun checkForUpdates(context: Context, fromAvailableExtensionList: Boolean = false): List<Extension.Installed>? {
context: Context,
fromAvailableExtensionList: Boolean = false,
): List<Extension.Installed>? {
// Limit checks to once a day at most // Limit checks to once a day at most
if (!fromAvailableExtensionList && if (!fromAvailableExtensionList &&
Instant.now().toEpochMilli() < lastExtCheck.get() + 1.days.inWholeMilliseconds Instant.now().toEpochMilli() < lastExtCheck.get() + 1.days.inWholeMilliseconds
@ -111,7 +123,10 @@ internal class ExtensionGithubApi {
return extensionsWithUpdate 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 return this
.filter { .filter {
val libVersion = it.extractLibVersion() val libVersion = it.extractLibVersion()
@ -128,13 +143,15 @@ 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 = "${getUrlPrefix()}icon/${it.pkg}.png", iconUrl = "${repoUrl}icon/${it.pkg}.png",
repoUrl = repoUrl,
isRepoSource = repoSource,
) )
} }
} }
fun getApkUrl(extension: Extension.Available): String { fun getApkUrl(extension: Extension.Available): String {
return "${getUrlPrefix()}apk/${extension.apkName}" return "${extension.repoUrl}/apk/${extension.apkName}"
} }
private fun getUrlPrefix(): String { 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 BASE_URL = "https://raw.githubusercontent.com/"
private const val FALLBACK_REPO_URL_PREFIX = "https://gcore.jsdelivr.net/gh/tachiyomiorg/tachiyomi-extensions@repo/" 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(

View File

@ -29,6 +29,8 @@ sealed class Extension {
val isObsolete: Boolean = false, val isObsolete: Boolean = false,
val isUnofficial: Boolean = false, val isUnofficial: Boolean = false,
val isShared: Boolean, val isShared: Boolean,
val repoUrl: String? = null,
val isRepoSource: Boolean = false,
) : Extension() ) : Extension()
data class Available( data class Available(
@ -42,6 +44,8 @@ sealed class Extension {
val sources: List<Source>, val sources: List<Source>,
val apkName: String, val apkName: String,
val iconUrl: String, val iconUrl: String,
val repoUrl: String,
val isRepoSource: Boolean,
) : Extension() { ) : Extension() {
data class Source( data class Source(

View File

@ -24,6 +24,8 @@ import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.util.removeCovers 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.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.emptyFlow
@ -265,7 +267,10 @@ class BrowseSourceScreenModel(
else -> { else -> {
val preselectedIds = getCategories.await(manga.id).map { it.id } val preselectedIds = getCategories.await(manga.id).map { it.id }
setDialog( 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 AddDuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog
data class ChangeMangaCategory( data class ChangeMangaCategory(
val manga: Manga, val manga: Manga,
val initialSelection: List<CheckboxState.State<Category>>, val initialSelection: ImmutableList<CheckboxState.State<Category>>,
) : Dialog ) : Dialog
data class Migrate(val newManga: Manga) : Dialog data class Migrate(val newManga: Manga) : Dialog
} }

View File

@ -5,6 +5,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.util.fastMap
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
@ -15,7 +16,10 @@ import eu.kanade.presentation.category.components.CategoryRenameDialog
import eu.kanade.presentation.category.components.CategorySortAlphabeticallyDialog import eu.kanade.presentation.category.components.CategorySortAlphabeticallyDialog
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.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() {
@ -52,22 +56,24 @@ class CategoryScreen : Screen() {
CategoryCreateDialog( CategoryCreateDialog(
onDismissRequest = screenModel::dismissDialog, onDismissRequest = screenModel::dismissDialog,
onCreate = screenModel::createCategory, onCreate = screenModel::createCategory,
categories = successState.categories, categories = successState.categories.fastMap { it.name }.toImmutableList(),
title = stringResource(MR.strings.action_add_category),
) )
} }
is CategoryDialog.Rename -> { is CategoryDialog.Rename -> {
CategoryRenameDialog( CategoryRenameDialog(
onDismissRequest = screenModel::dismissDialog, onDismissRequest = screenModel::dismissDialog,
onRename = { screenModel.renameCategory(dialog.category, it) }, onRename = { screenModel.renameCategory(dialog.category, it) },
categories = successState.categories, categories = successState.categories.fastMap { it.name }.toImmutableList(),
category = dialog.category, category = dialog.category.name,
) )
} }
is CategoryDialog.Delete -> { is CategoryDialog.Delete -> {
CategoryDeleteDialog( CategoryDeleteDialog(
onDismissRequest = screenModel::dismissDialog, onDismissRequest = screenModel::dismissDialog,
onDelete = { screenModel.deleteCategory(dialog.category.id) }, 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 -> { is CategoryDialog.SortAlphabetically -> {

View File

@ -28,9 +28,11 @@ import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.chapter.getNextUnread import eu.kanade.tachiyomi.util.chapter.getNextUnread
import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.removeCovers
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.PersistentList
import kotlinx.collections.immutable.mutate import kotlinx.collections.immutable.mutate
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -661,13 +663,15 @@ class LibraryScreenModel(
val common = getCommonCategories(mangaList) val common = getCommonCategories(mangaList)
// Get indexes of the mix categories to preselect. // Get indexes of the mix categories to preselect.
val mix = getMixCategories(mangaList) val mix = getMixCategories(mangaList)
val preselected = categories.map { val preselected = categories
.map {
when (it) { when (it) {
in common -> CheckboxState.State.Checked(it) in common -> CheckboxState.State.Checked(it)
in mix -> CheckboxState.TriState.Exclude(it) in mix -> CheckboxState.TriState.Exclude(it)
else -> CheckboxState.State.None(it) else -> CheckboxState.State.None(it)
} }
} }
.toImmutableList()
mutableState.update { it.copy(dialog = Dialog.ChangeCategory(mangaList, preselected)) } mutableState.update { it.copy(dialog = Dialog.ChangeCategory(mangaList, preselected)) }
} }
} }
@ -683,7 +687,10 @@ class LibraryScreenModel(
sealed interface Dialog { sealed interface Dialog {
data object SettingsSheet : 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 data class DeleteManga(val manga: List<Manga>) : Dialog
} }

View File

@ -36,6 +36,8 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.util.chapter.getNextUnread import eu.kanade.tachiyomi.util.chapter.getNextUnread
import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.removeCovers
import eu.kanade.tachiyomi.util.shouldDownloadNewChapters import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
@ -360,7 +362,7 @@ class MangaScreenModel(
successState.copy( successState.copy(
dialog = Dialog.ChangeCategory( dialog = Dialog.ChangeCategory(
manga = manga, 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 // Track sheet - end
sealed interface Dialog { 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 DeleteChapters(val chapters: List<Chapter>) : Dialog
data class DuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog data class DuplicateManga(val manga: Manga, val duplicate: Manga) : Dialog
data class SetFetchInterval(val manga: Manga) : Dialog data class SetFetchInterval(val manga: Manga) : Dialog

View File

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

View File

@ -336,6 +336,17 @@
<string name="ext_installer_shizuku_stopped">Shizuku is not running</string> <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> <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 --> <!-- Reader section -->
<string name="pref_fullscreen">Fullscreen</string> <string name="pref_fullscreen">Fullscreen</string>
<string name="pref_show_navigation_mode">Show tap zones overlay</string> <string name="pref_show_navigation_mode">Show tap zones overlay</string>