Convert Source tab to use Compose (#6987)

* Use Compose in Source tab

* Replace hashCode with key function

* Add ability to turn off pins moving on top of source list

* Changes from review comments
This commit is contained in:
Andreas 2022-04-24 20:35:59 +02:00 committed by GitHub
parent 558b18899c
commit 29a0989f28
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 705 additions and 538 deletions

View file

@ -0,0 +1,13 @@
package eu.kanade.data.source
import eu.kanade.domain.source.model.Source
import eu.kanade.tachiyomi.source.CatalogueSource
val sourceMapper: (CatalogueSource) -> Source = { source ->
Source(
source.id,
source.lang,
source.name,
source.supportsLatest
)
}

View file

@ -0,0 +1,18 @@
package eu.kanade.data.source
import eu.kanade.domain.source.model.Source
import eu.kanade.domain.source.repository.SourceRepository
import eu.kanade.tachiyomi.source.SourceManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class SourceRepositoryImpl(
private val sourceManager: SourceManager
) : SourceRepository {
override fun getSources(): Flow<List<Source>> {
return sourceManager.catalogueSources.map { sources ->
sources.map(sourceMapper)
}
}
}

View file

@ -1,12 +1,17 @@
package eu.kanade.domain
import eu.kanade.data.history.HistoryRepositoryImpl
import eu.kanade.data.source.SourceRepositoryImpl
import eu.kanade.domain.history.interactor.DeleteHistoryTable
import eu.kanade.domain.history.interactor.GetHistory
import eu.kanade.domain.history.interactor.GetNextChapterForManga
import eu.kanade.domain.history.interactor.RemoveHistoryById
import eu.kanade.domain.history.interactor.RemoveHistoryByMangaId
import eu.kanade.domain.history.repository.HistoryRepository
import eu.kanade.domain.source.interactor.DisableSource
import eu.kanade.domain.source.interactor.GetEnabledSources
import eu.kanade.domain.source.interactor.ToggleSourcePin
import eu.kanade.domain.source.repository.SourceRepository
import uy.kohesive.injekt.api.InjektModule
import uy.kohesive.injekt.api.InjektRegistrar
import uy.kohesive.injekt.api.addFactory
@ -22,5 +27,9 @@ class DomainModule : InjektModule {
addFactory { GetNextChapterForManga(get()) }
addFactory { RemoveHistoryById(get()) }
addFactory { RemoveHistoryByMangaId(get()) }
addSingletonFactory<SourceRepository> { SourceRepositoryImpl(get()) }
addFactory { GetEnabledSources(get(), get()) }
addFactory { DisableSource(get()) }
addFactory { ToggleSourcePin(get()) }
}
}

View file

@ -0,0 +1,14 @@
package eu.kanade.domain.source.interactor
import eu.kanade.domain.source.model.Source
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.preference.plusAssign
class DisableSource(
private val preferences: PreferencesHelper
) {
fun await(source: Source) {
preferences.disabledSources() += source.id.toString()
}
}

View file

@ -0,0 +1,57 @@
package eu.kanade.domain.source.interactor
import eu.kanade.domain.source.model.Pin
import eu.kanade.domain.source.model.Pins
import eu.kanade.domain.source.model.Source
import eu.kanade.domain.source.repository.SourceRepository
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.LocalSource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
class GetEnabledSources(
private val repository: SourceRepository,
private val preferences: PreferencesHelper
) {
fun subscribe(): Flow<List<Source>> {
return preferences.pinnedSources().asFlow()
.combine(preferences.enabledLanguages().asFlow()) { pinList, enabledLanguages ->
Config(pinSet = pinList, enabledSources = enabledLanguages)
}
.combine(preferences.disabledSources().asFlow()) { config, disabledSources ->
config.copy(disabledSources = disabledSources)
}
.combine(preferences.lastUsedSource().asFlow()) { config, lastUsedSource ->
config.copy(lastUsedSource = lastUsedSource)
}
.combine(repository.getSources()) { (pinList, enabledLanguages, disabledSources, lastUsedSource), sources ->
val pinsOnTop = preferences.pinsOnTop().get()
sources
.filter { it.lang in enabledLanguages || it.id == LocalSource.ID }
.filterNot { it.id.toString() in disabledSources }
.flatMap {
val flag = if ("${it.id}" in pinList) Pins.pinned else Pins.unpinned
val source = it.copy(pin = flag)
val toFlatten = mutableListOf(source)
if (source.id == lastUsedSource) {
toFlatten.add(source.copy(isUsedLast = true, pin = source.pin - Pin.Actual))
}
if (pinsOnTop.not() && Pin.Pinned in source.pin) {
toFlatten[0] = toFlatten[0].copy(pin = source.pin + Pin.Forced)
toFlatten.add(source.copy(pin = source.pin - Pin.Actual))
}
toFlatten
}
}
.distinctUntilChanged()
}
}
private data class Config(
val pinSet: Set<String> = setOf(),
val enabledSources: Set<String> = setOf(),
val disabledSources: Set<String> = setOf(),
val lastUsedSource: Long? = null
)

View file

@ -0,0 +1,20 @@
package eu.kanade.domain.source.interactor
import eu.kanade.domain.source.model.Source
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.preference.minusAssign
import eu.kanade.tachiyomi.util.preference.plusAssign
class ToggleSourcePin(
private val preferences: PreferencesHelper
) {
fun await(source: Source) {
val isPinned = source.id.toString() in preferences.pinnedSources().get()
if (isPinned) {
preferences.pinnedSources() -= source.id.toString()
} else {
preferences.pinnedSources() += source.id.toString()
}
}
}

View file

@ -0,0 +1,78 @@
package eu.kanade.domain.source.model
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.core.graphics.drawable.toBitmap
import eu.kanade.tachiyomi.extension.ExtensionManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
data class Source(
val id: Long,
val lang: String,
val name: String,
val supportsLatest: Boolean,
val pin: Pins = Pins.unpinned,
val isUsedLast: Boolean = false
) {
val nameWithLanguage: String
get() = "$name (${lang.uppercase()})"
val icon: ImageBitmap?
get() {
return Injekt.get<ExtensionManager>().getAppIconForSource(id)
?.toBitmap()
?.asImageBitmap()
}
val key: () -> Long = {
when {
isUsedLast -> id shr 16
Pin.Forced in pin -> id shr 32
else -> id
}
}
}
sealed class Pin(val code: Int) {
object Unpinned : Pin(0b00)
object Pinned : Pin(0b01)
object Actual : Pin(0b10)
object Forced : Pin(0b100)
}
inline fun Pins(builder: Pins.PinsBuilder.() -> Unit = {}): Pins {
return Pins.PinsBuilder().apply(builder).flags()
}
fun Pins(vararg pins: Pin) = Pins {
pins.forEach { +it }
}
data class Pins(val code: Int = Pin.Unpinned.code) {
operator fun contains(pin: Pin): Boolean = pin.code and code == pin.code
operator fun plus(pin: Pin): Pins = Pins(code or pin.code)
operator fun minus(pin: Pin): Pins = Pins(code xor pin.code)
companion object {
val unpinned = Pins(Pin.Unpinned)
val pinned = Pins(Pin.Pinned, Pin.Actual)
}
class PinsBuilder(var code: Int = 0) {
operator fun Pin.unaryPlus() {
this@PinsBuilder.code = code or this@PinsBuilder.code
}
operator fun Pin.unaryMinus() {
this@PinsBuilder.code = code or this@PinsBuilder.code
}
fun flags(): Pins = Pins(code)
}
}

View file

@ -0,0 +1,9 @@
package eu.kanade.domain.source.repository
import eu.kanade.domain.source.model.Source
import kotlinx.coroutines.flow.Flow
interface SourceRepository {
fun getSources(): Flow<List<Source>>
}

View file

@ -0,0 +1,282 @@
package eu.kanade.presentation.source
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import eu.kanade.domain.source.model.Pin
import eu.kanade.domain.source.model.Source
import eu.kanade.presentation.components.EmptyScreen
import eu.kanade.presentation.theme.header
import eu.kanade.presentation.util.horizontalPadding
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.ui.browse.source.SourcePresenter
import eu.kanade.tachiyomi.ui.browse.source.UiModel
import eu.kanade.tachiyomi.util.system.LocaleHelper
@Composable
fun SourceScreen(
nestedScrollInterop: NestedScrollConnection,
presenter: SourcePresenter,
onClickItem: (Source) -> Unit,
onClickDisable: (Source) -> Unit,
onClickLatest: (Source) -> Unit,
onClickPin: (Source) -> Unit,
) {
val state by presenter.state.collectAsState()
when {
state.isLoading -> CircularProgressIndicator()
state.hasError -> Text(text = state.error!!.message!!)
state.isEmpty -> EmptyScreen(message = "")
else -> SourceList(
nestedScrollConnection = nestedScrollInterop,
list = state.sources,
onClickItem = onClickItem,
onClickDisable = onClickDisable,
onClickLatest = onClickLatest,
onClickPin = onClickPin,
)
}
}
@Composable
fun SourceList(
nestedScrollConnection: NestedScrollConnection,
list: List<UiModel>,
onClickItem: (Source) -> Unit,
onClickDisable: (Source) -> Unit,
onClickLatest: (Source) -> Unit,
onClickPin: (Source) -> Unit,
) {
val (sourceState, setSourceState) = remember { mutableStateOf<Source?>(null) }
LazyColumn(
modifier = Modifier
.nestedScroll(nestedScrollConnection),
contentPadding = WindowInsets.navigationBars.asPaddingValues(),
) {
items(
items = list,
contentType = {
when (it) {
is UiModel.Header -> "header"
is UiModel.Item -> "item"
}
},
key = {
when (it) {
is UiModel.Header -> it.hashCode()
is UiModel.Item -> it.source.key()
}
}
) { model ->
when (model) {
is UiModel.Header -> {
SourceHeader(
modifier = Modifier.animateItemPlacement(),
language = model.language
)
}
is UiModel.Item -> SourceItem(
modifier = Modifier.animateItemPlacement(),
item = model.source,
onClickItem = onClickItem,
onLongClickItem = {
setSourceState(it)
},
onClickLatest = onClickLatest,
onClickPin = onClickPin,
)
}
}
}
if (sourceState != null) {
SourceOptionsDialog(
source = sourceState,
onClickPin = {
onClickPin(sourceState)
setSourceState(null)
},
onClickDisable = {
onClickDisable(sourceState)
setSourceState(null)
},
onDismiss = { setSourceState(null) }
)
}
}
@Composable
fun SourceHeader(
modifier: Modifier = Modifier,
language: String
) {
val context = LocalContext.current
Text(
text = LocaleHelper.getSourceDisplayName(language, context),
modifier = modifier
.padding(horizontal = horizontalPadding, vertical = 8.dp),
style = MaterialTheme.typography.header
)
}
@Composable
fun SourceItem(
modifier: Modifier = Modifier,
item: Source,
onClickItem: (Source) -> Unit,
onLongClickItem: (Source) -> Unit,
onClickLatest: (Source) -> Unit,
onClickPin: (Source) -> Unit
) {
Row(
modifier = modifier
.combinedClickable(
onClick = { onClickItem(item) },
onLongClick = { onLongClickItem(item) }
)
.padding(horizontal = horizontalPadding, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
SourceIcon(source = item)
Column(
modifier = Modifier
.padding(horizontal = horizontalPadding)
.weight(1f)
) {
Text(
text = item.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium
)
Text(
text = LocaleHelper.getDisplayName(item.lang),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall
)
}
if (item.supportsLatest) {
TextButton(onClick = { onClickLatest(item) }) {
Text(text = stringResource(id = R.string.latest))
}
}
SourcePinButton(
isPinned = Pin.Pinned in item.pin,
onClick = { onClickPin(item) }
)
}
}
@Composable
fun SourceIcon(
source: Source
) {
val icon = source.icon
val modifier = Modifier
.height(40.dp)
.aspectRatio(1f)
if (icon != null) {
Image(
bitmap = icon,
contentDescription = "",
modifier = modifier,
)
} else {
Image(
painter = painterResource(id = R.mipmap.ic_local_source),
contentDescription = "",
modifier = modifier,
)
}
}
@Composable
fun SourcePinButton(
isPinned: Boolean,
onClick: () -> Unit
) {
val icon = if (isPinned) Icons.Filled.PushPin else Icons.Outlined.PushPin
val tint = if (isPinned) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onBackground
IconButton(onClick = onClick) {
Icon(
imageVector = icon,
contentDescription = "",
tint = tint
)
}
}
@Composable
fun SourceOptionsDialog(
source: Source,
onClickPin: () -> Unit,
onClickDisable: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
title = {
Text(text = source.nameWithLanguage)
},
text = {
Column {
val textId = if (Pin.Pinned in source.pin) R.string.action_unpin else R.string.action_pin
Text(
text = stringResource(id = textId),
modifier = Modifier
.clickable(onClick = onClickPin)
.fillMaxWidth()
.padding(vertical = 16.dp)
)
if (source.id != LocalSource.ID) {
Text(
text = stringResource(id = R.string.action_disable),
modifier = Modifier
.clickable(onClick = onClickDisable)
.fillMaxWidth()
.padding(vertical = 16.dp)
)
}
}
},
onDismissRequest = onDismiss,
confirmButton = {},
)
}

View file

@ -9,7 +9,8 @@ import com.google.android.material.composethemeadapter3.createMdc3Theme
fun TachiyomiTheme(content: @Composable () -> Unit) {
val context = LocalContext.current
val (colorScheme, typography) = createMdc3Theme(
context = context
context = context,
setTextColors = true
)
MaterialTheme(

View file

@ -0,0 +1,16 @@
package eu.kanade.presentation.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Typography
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
val Typography.header: TextStyle
@Composable
get() {
return bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold
)
}

View file

@ -324,6 +324,8 @@ class PreferencesHelper(val context: Context) {
fun autoClearChapterCache() = prefs.getBoolean(Keys.autoClearChapterCache, false)
fun pinsOnTop() = flowPrefs.getBoolean("pins_on_top", true)
fun setChapterSettingsDefault(manga: Manga) {
prefs.edit {
putInt(Keys.defaultChapterFilterByRead, manga.readFilter)

View file

@ -66,7 +66,11 @@ class ExtensionManager(
}
fun getAppIconForSource(source: Source): Drawable? {
val pkgName = installedExtensions.find { ext -> ext.sources.any { it.id == source.id } }?.pkgName
return getAppIconForSource(source.id)
}
fun getAppIconForSource(sourceId: Long): Drawable? {
val pkgName = installedExtensions.find { ext -> ext.sources.any { it.id == sourceId } }?.pkgName
if (pkgName != null) {
return iconMap[pkgName] ?: iconMap.getOrPut(pkgName) { context.packageManager.getApplicationIcon(pkgName) }
}

View file

@ -6,6 +6,9 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import rx.Observable
open class SourceManager(private val context: Context) {
@ -13,6 +16,9 @@ open class SourceManager(private val context: Context) {
private val sourcesMap = mutableMapOf<Long, Source>()
private val stubSourcesMap = mutableMapOf<Long, StubSource>()
private val _catalogueSources: MutableStateFlow<List<CatalogueSource>> = MutableStateFlow(listOf())
val catalogueSources: Flow<List<CatalogueSource>> = _catalogueSources
init {
createInternalSources().forEach { registerSource(it) }
}
@ -38,10 +44,18 @@ open class SourceManager(private val context: Context) {
if (!stubSourcesMap.containsKey(source.id)) {
stubSourcesMap[source.id] = StubSource(source.id)
}
triggerCatalogueSources()
}
internal fun unregisterSource(source: Source) {
sourcesMap.remove(source.id)
triggerCatalogueSources()
}
private fun triggerCatalogueSources() {
_catalogueSources.update {
sourcesMap.values.filterIsInstance<CatalogueSource>()
}
}
private fun createInternalSources(): List<Source> = listOf(

View file

@ -7,6 +7,7 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import eu.kanade.presentation.theme.TachiyomiTheme
import eu.kanade.tachiyomi.databinding.ComposeControllerBinding
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import nucleus.presenter.Presenter
/**
@ -52,3 +53,22 @@ abstract class BasicComposeController : BaseController<ComposeControllerBinding>
@Composable abstract fun ComposeContent(nestedScrollInterop: NestedScrollConnection)
}
abstract class SearchableComposeController<P : BasePresenter<*>> : SearchableNucleusController<ComposeControllerBinding, P>() {
override fun createBinding(inflater: LayoutInflater): ComposeControllerBinding =
ComposeControllerBinding.inflate(inflater)
override fun onViewCreated(view: View) {
super.onViewCreated(view)
binding.root.setContent {
val nestedScrollInterop = rememberNestedScrollInteropConnection(binding.root)
TachiyomiTheme {
ComposeContent(nestedScrollInterop)
}
}
}
@Composable abstract fun ComposeContent(nestedScrollInterop: NestedScrollConnection)
}

View file

@ -1,17 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source
import android.view.View
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.databinding.SectionHeaderItemBinding
import eu.kanade.tachiyomi.util.system.LocaleHelper
class LangHolder(view: View, adapter: FlexibleAdapter<*>) :
FlexibleViewHolder(view, adapter) {
private val binding = SectionHeaderItemBinding.bind(view)
fun bind(item: LangItem) {
binding.title.text = LocaleHelper.getSourceDisplayName(item.code, itemView.context)
}
}

View file

@ -1,42 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractHeaderItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
/**
* Item that contains the language header.
*
* @param code The lang code.
*/
data class LangItem(val code: String) : AbstractHeaderItem<LangHolder>() {
/**
* Returns the layout resource of this item.
*/
override fun getLayoutRes(): Int {
return R.layout.section_header_item
}
/**
* Creates a new view holder for this item.
*/
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): LangHolder {
return LangHolder(view, adapter)
}
/**
* Binds this item to the given view holder.
*/
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: LangHolder,
position: Int,
payloads: MutableList<Any>,
) {
holder.bind(this)
}
}

View file

@ -1,32 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
/**
* Adapter that holds the catalogue cards.
*
* @param controller instance of [SourceController].
*/
class SourceAdapter(controller: SourceController) :
FlexibleAdapter<IFlexible<*>>(null, controller, true) {
init {
setDisplayHeadersAtStartUp(true)
}
/**
* Listener for browse item clicks.
*/
val clickListener: OnSourceClickListener = controller
/**
* Listener which should be called when user clicks browse.
* Note: Should only be handled by [SourceController]
*/
interface OnSourceClickListener {
fun onBrowseClick(position: Int)
fun onLatestClick(position: Int)
fun onPinClick(position: Int)
}
}

View file

@ -1,182 +1,76 @@
package eu.kanade.tachiyomi.ui.browse.source
import android.Manifest.permission.WRITE_EXTERNAL_STORAGE
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import androidx.recyclerview.widget.LinearLayoutManager
import com.bluelinelabs.conductor.ControllerChangeHandler
import com.bluelinelabs.conductor.ControllerChangeType
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dev.chrisbanes.insetter.applyInsetter
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import eu.kanade.domain.source.model.Source
import eu.kanade.presentation.source.SourceScreen
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.databinding.SourceMainControllerBinding
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.ui.base.controller.DialogController
import eu.kanade.tachiyomi.ui.base.controller.SearchableNucleusController
import eu.kanade.tachiyomi.ui.base.controller.SearchableComposeController
import eu.kanade.tachiyomi.ui.base.controller.pushController
import eu.kanade.tachiyomi.ui.base.controller.requestPermissionsSafe
import eu.kanade.tachiyomi.ui.browse.BrowseController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchController
import eu.kanade.tachiyomi.ui.browse.source.latest.LatestUpdatesController
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.util.preference.minusAssign
import eu.kanade.tachiyomi.util.preference.plusAssign
import eu.kanade.tachiyomi.util.view.onAnimationsFinished
import uy.kohesive.injekt.injectLazy
/**
* This controller shows and manages the different catalogues enabled by the user.
* This controller should only handle UI actions, IO actions should be done by [SourcePresenter]
* [SourceAdapter.OnSourceClickListener] call function data on browse item click.
* [SourceAdapter.OnLatestClickListener] call function data on latest item click
*/
class SourceController :
SearchableNucleusController<SourceMainControllerBinding, SourcePresenter>(),
FlexibleAdapter.OnItemClickListener,
FlexibleAdapter.OnItemLongClickListener,
SourceAdapter.OnSourceClickListener {
class SourceController : SearchableComposeController<SourcePresenter>() {
private val preferences: PreferencesHelper by injectLazy()
private var adapter: SourceAdapter? = null
init {
setHasOptionsMenu(true)
}
override fun getTitle(): String? {
return applicationContext?.getString(R.string.label_sources)
}
override fun getTitle(): String? =
resources?.getString(R.string.label_sources)
override fun createPresenter(): SourcePresenter {
return SourcePresenter()
}
override fun createPresenter(): SourcePresenter =
SourcePresenter()
override fun createBinding(inflater: LayoutInflater) = SourceMainControllerBinding.inflate(inflater)
@Composable
override fun ComposeContent(nestedScrollInterop: NestedScrollConnection) {
SourceScreen(
nestedScrollInterop = nestedScrollInterop,
presenter = presenter,
onClickItem = { source ->
openSource(source, BrowseSourceController(source))
},
onClickDisable = { source ->
presenter.disableSource(source)
},
onClickLatest = { source ->
openSource(source, LatestUpdatesController(source))
},
onClickPin = { source ->
presenter.togglePin(source)
},
)
LaunchedEffect(Unit) {
(activity as? MainActivity)?.ready = true
}
}
override fun onViewCreated(view: View) {
super.onViewCreated(view)
binding.recycler.applyInsetter {
type(navigationBars = true) {
padding()
}
}
adapter = SourceAdapter(this)
// Create recycler and set adapter.
binding.recycler.layoutManager = LinearLayoutManager(view.context)
binding.recycler.adapter = adapter
binding.recycler.onAnimationsFinished {
(activity as? MainActivity)?.ready = true
}
adapter?.fastScroller = binding.fastScroller
requestPermissionsSafe(arrayOf(WRITE_EXTERNAL_STORAGE), 301)
// Update list on extension changes (e.g. new installation)
(parentController as BrowseController).extensionListUpdateRelay
.skip(1) // Skip first update when ExtensionController created
.subscribeUntilDestroy {
presenter.updateSources()
}
}
override fun onDestroyView(view: View) {
adapter = null
super.onDestroyView(view)
}
override fun onChangeStarted(handler: ControllerChangeHandler, type: ControllerChangeType) {
super.onChangeStarted(handler, type)
if (type.isPush) {
presenter.updateSources()
}
}
override fun onItemClick(view: View, position: Int): Boolean {
onItemClick(position)
return false
}
private fun onItemClick(position: Int) {
val item = adapter?.getItem(position) as? SourceItem ?: return
val source = item.source
openSource(source, BrowseSourceController(source))
}
override fun onItemLongClick(position: Int) {
val activity = activity ?: return
val item = adapter?.getItem(position) as? SourceItem ?: return
val isPinned = item.header?.code?.equals(SourcePresenter.PINNED_KEY) ?: false
val items = mutableListOf(
activity.getString(if (isPinned) R.string.action_unpin else R.string.action_pin) to { toggleSourcePin(item.source) },
)
if (item.source !is LocalSource) {
items.add(activity.getString(R.string.action_disable) to { disableSource(item.source) })
}
SourceOptionsDialog(item.source.toString(), items).showDialog(router)
}
private fun disableSource(source: Source) {
preferences.disabledSources() += source.id.toString()
presenter.updateSources()
}
private fun toggleSourcePin(source: Source) {
val isPinned = source.id.toString() in preferences.pinnedSources().get()
if (isPinned) {
preferences.pinnedSources() -= source.id.toString()
} else {
preferences.pinnedSources() += source.id.toString()
}
presenter.updateSources()
}
/**
* Called when browse is clicked in [SourceAdapter]
*/
override fun onBrowseClick(position: Int) {
onItemClick(position)
}
/**
* Called when latest is clicked in [SourceAdapter]
*/
override fun onLatestClick(position: Int) {
val item = adapter?.getItem(position) as? SourceItem ?: return
openSource(item.source, LatestUpdatesController(item.source))
}
/**
* Called when pin icon is clicked in [SourceAdapter]
*/
override fun onPinClick(position: Int) {
val item = adapter?.getItem(position) as? SourceItem ?: return
toggleSourcePin(item.source)
}
/**
* Opens a catalogue with the given controller.
*/
private fun openSource(source: CatalogueSource, controller: BrowseSourceController) {
private fun openSource(source: Source, controller: BrowseSourceController) {
if (!preferences.incognitoMode().get()) {
preferences.lastUsedSource().set(source.id)
}
@ -190,51 +84,13 @@ class SourceController :
* @return True if this event has been consumed, false if it has not.
*/
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
return when (item.itemId) {
// Initialize option to open catalogue settings.
R.id.action_settings -> {
parentController!!.router.pushController(SourceFilterController())
true
}
}
return super.onOptionsItemSelected(item)
}
/**
* Called to update adapter containing sources.
*/
fun setSources(sources: List<IFlexible<*>>) {
adapter?.updateDataSet(sources)
}
/**
* Called to set the last used catalogue at the top of the view.
*/
fun setLastUsedSource(item: SourceItem?) {
adapter?.removeAllScrollableHeaders()
if (item != null) {
adapter?.addScrollableHeader(item)
adapter?.addScrollableHeader(LangItem(SourcePresenter.LAST_USED_KEY))
}
}
class SourceOptionsDialog(bundle: Bundle? = null) : DialogController(bundle) {
private lateinit var source: String
private lateinit var items: List<Pair<String, () -> Unit>>
constructor(source: String, items: List<Pair<String, () -> Unit>>) : this() {
this.source = source
this.items = items
}
override fun onCreateDialog(savedViewState: Bundle?): Dialog {
return MaterialAlertDialogBuilder(activity!!)
.setTitle(source)
.setItems(items.map { it.first }.toTypedArray()) { dialog, which ->
items[which].second()
dialog.dismiss()
}
.create()
else -> super.onOptionsItemSelected(item)
}
}

View file

@ -1,52 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source
import android.view.View
import androidx.core.view.isVisible
import coil.load
import eu.davidea.viewholders.FlexibleViewHolder
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.SourceMainControllerItemBinding
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.icon
import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.view.setVectorCompat
class SourceHolder(view: View, val adapter: SourceAdapter) :
FlexibleViewHolder(view, adapter) {
private val binding = SourceMainControllerItemBinding.bind(view)
init {
binding.sourceLatest.setOnClickListener {
adapter.clickListener.onLatestClick(bindingAdapterPosition)
}
binding.pin.setOnClickListener {
adapter.clickListener.onPinClick(bindingAdapterPosition)
}
}
fun bind(item: SourceItem) {
val source = item.source
binding.title.text = source.name
binding.subtitle.isVisible = source !is LocalSource
binding.subtitle.text = LocaleHelper.getDisplayName(source.lang)
// Set source icon
val icon = source.icon()
when {
icon != null -> binding.image.load(icon)
item.source.id == LocalSource.ID -> binding.image.load(R.mipmap.ic_local_source)
}
binding.sourceLatest.isVisible = source.supportsLatest
binding.pin.isVisible = true
if (item.isPinned) {
binding.pin.setVectorCompat(R.drawable.ic_push_pin_24dp, R.attr.colorAccent)
} else {
binding.pin.setVectorCompat(R.drawable.ic_push_pin_outline_24dp, android.R.attr.textColorHint)
}
}
}

View file

@ -1,56 +0,0 @@
package eu.kanade.tachiyomi.ui.browse.source
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.AbstractSectionableItem
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.CatalogueSource
/**
* Item that contains source information.
*
* @param source Instance of [CatalogueSource] containing source information.
* @param header The header for this item.
*/
data class SourceItem(
val source: CatalogueSource,
val header: LangItem? = null,
val isPinned: Boolean = false,
) :
AbstractSectionableItem<SourceHolder, LangItem>(header) {
override fun getLayoutRes(): Int {
return R.layout.source_main_controller_item
}
override fun createViewHolder(view: View, adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>): SourceHolder {
return SourceHolder(view, adapter as SourceAdapter)
}
override fun bindViewHolder(
adapter: FlexibleAdapter<IFlexible<RecyclerView.ViewHolder>>,
holder: SourceHolder,
position: Int,
payloads: MutableList<Any>,
) {
holder.bind(this)
}
override fun equals(other: Any?): Boolean {
if (other is SourceItem) {
return source.id == other.source.id &&
getHeader()?.code == other.getHeader()?.code &&
isPinned == other.isPinned
}
return false
}
override fun hashCode(): Int {
var result = source.id.hashCode()
result = 31 * result + (header?.hashCode() ?: 0)
result = 31 * result + isPinned.hashCode()
return result
}
}

View file

@ -1,16 +1,19 @@
package eu.kanade.tachiyomi.ui.browse.source
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.SourceManager
import android.os.Bundle
import eu.kanade.domain.source.interactor.DisableSource
import eu.kanade.domain.source.interactor.GetEnabledSources
import eu.kanade.domain.source.interactor.ToggleSourcePin
import eu.kanade.domain.source.model.Pin
import eu.kanade.domain.source.model.Source
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import eu.kanade.tachiyomi.util.lang.launchIO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.TreeMap
@ -20,87 +23,68 @@ import java.util.TreeMap
* Function calls should be done from here. UI calls should be done from the controller.
*/
class SourcePresenter(
val sourceManager: SourceManager = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get(),
private val getEnabledSources: GetEnabledSources = Injekt.get(),
private val disableSource: DisableSource = Injekt.get(),
private val toggleSourcePin: ToggleSourcePin = Injekt.get()
) : BasePresenter<SourceController>() {
var sources = getEnabledSources()
private val _state: MutableStateFlow<SourceState> = MutableStateFlow(SourceState.EMPTY)
val state: StateFlow<SourceState> = _state.asStateFlow()
/**
* Unsubscribe and create a new subscription to fetch enabled sources.
*/
private fun loadSources() {
val pinnedSources = mutableListOf<SourceItem>()
val pinnedSourceIds = preferences.pinnedSources().get()
override fun onCreate(savedState: Bundle?) {
super.onCreate(savedState)
presenterScope.launchIO {
getEnabledSources.subscribe()
.catch { exception ->
_state.update { state ->
state.copy(sources = listOf(), error = exception)
}
}
.collectLatest(::collectLatestSources)
}
}
val map = TreeMap<String, MutableList<CatalogueSource>> { d1, d2 ->
private suspend fun collectLatestSources(sources: List<Source>) {
val map = TreeMap<String, MutableList<Source>> { d1, d2 ->
// Catalogues without a lang defined will be placed at the end
when {
d1 == LAST_USED_KEY && d2 != LAST_USED_KEY -> -1
d2 == LAST_USED_KEY && d1 != LAST_USED_KEY -> 1
d1 == PINNED_KEY && d2 != PINNED_KEY -> -1
d2 == PINNED_KEY && d1 != PINNED_KEY -> 1
d1 == "" && d2 != "" -> 1
d2 == "" && d1 != "" -> -1
else -> d1.compareTo(d2)
}
}
val byLang = sources.groupByTo(map) { it.lang }
var sourceItems = byLang.flatMap {
val langItem = LangItem(it.key)
it.value.map { source ->
val isPinned = source.id.toString() in pinnedSourceIds
if (isPinned) {
pinnedSources.add(SourceItem(source, LangItem(PINNED_KEY), isPinned))
}
SourceItem(source, langItem, isPinned)
val byLang = sources.groupByTo(map) {
when {
it.isUsedLast -> LAST_USED_KEY
Pin.Actual in it.pin -> PINNED_KEY
else -> it.lang
}
}
if (pinnedSources.isNotEmpty()) {
sourceItems = pinnedSources + sourceItems
_state.update { state ->
state.copy(
sources = byLang.flatMap {
listOf(
UiModel.Header(it.key),
*it.value.map { source ->
UiModel.Item(source)
}.toTypedArray()
)
},
error = null
)
}
view?.setSources(sourceItems)
}
private fun loadLastUsedSource() {
// Immediate initial load
preferences.lastUsedSource().get().let { updateLastUsedSource(it) }
// Subsequent updates
preferences.lastUsedSource().asFlow()
.drop(1)
.onStart { delay(500) }
.distinctUntilChanged()
.onEach { updateLastUsedSource(it) }
.launchIn(presenterScope)
fun disableSource(source: Source) {
disableSource.await(source)
}
private fun updateLastUsedSource(sourceId: Long) {
val source = (sourceManager.get(sourceId) as? CatalogueSource)?.let {
val isPinned = it.id.toString() in preferences.pinnedSources().get()
SourceItem(it, null, isPinned)
}
source?.let { view?.setLastUsedSource(it) }
}
fun updateSources() {
sources = getEnabledSources()
loadSources()
loadLastUsedSource()
}
/**
* Returns a list of enabled sources ordered by language and name.
*
* @return list containing enabled sources.
*/
private fun getEnabledSources(): List<CatalogueSource> {
val languages = preferences.enabledLanguages().get()
val disabledSourceIds = preferences.disabledSources().get()
return sourceManager.getCatalogueSources()
.filter { it.lang in languages || it.id == LocalSource.ID }
.filterNot { it.id.toString() in disabledSourceIds }
.sortedBy { "(${it.lang}) ${it.name.lowercase()}" }
fun togglePin(source: Source) {
toggleSourcePin.await(source)
}
companion object {
@ -108,3 +92,27 @@ class SourcePresenter(
const val LAST_USED_KEY = "last_used"
}
}
sealed class UiModel {
data class Item(val source: Source) : UiModel()
data class Header(val language: String) : UiModel()
}
data class SourceState(
val sources: List<UiModel>,
val error: Throwable?
) {
val isLoading: Boolean
get() = sources.isEmpty() && error == null
val hasError: Boolean
get() = error != null
val isEmpty: Boolean
get() = sources.isEmpty()
companion object {
val EMPTY = SourceState(listOf(), null)
}
}

View file

@ -20,6 +20,7 @@ import com.google.android.material.snackbar.Snackbar
import dev.chrisbanes.insetter.applyInsetter
import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible
import eu.kanade.domain.source.model.Source
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Category
import eu.kanade.tachiyomi.data.database.models.Manga
@ -69,16 +70,19 @@ open class BrowseSourceController(bundle: Bundle) :
FlexibleAdapter.EndlessScrollListener,
ChangeMangaCategoriesDialog.Listener {
constructor(source: CatalogueSource, searchQuery: String? = null) : this(
constructor(sourceId: Long, query: String? = null) : this(
Bundle().apply {
putLong(SOURCE_ID_KEY, source.id)
if (searchQuery != null) {
putString(SEARCH_QUERY_KEY, searchQuery)
putLong(SOURCE_ID_KEY, sourceId)
query?.let { query ->
putString(SEARCH_QUERY_KEY, query)
}
},
)
constructor(source: CatalogueSource, query: String? = null) : this(source.id, query)
constructor(source: Source, query: String? = null) : this(source.id, query)
private val preferences: PreferencesHelper by injectLazy()
/**

View file

@ -3,8 +3,8 @@ package eu.kanade.tachiyomi.ui.browse.source.latest
import android.os.Bundle
import android.view.Menu
import androidx.core.os.bundleOf
import eu.kanade.domain.source.model.Source
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.source.CatalogueSource
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceController
import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
@ -13,7 +13,7 @@ import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourcePresenter
*/
class LatestUpdatesController(bundle: Bundle) : BrowseSourceController(bundle) {
constructor(source: CatalogueSource) : this(
constructor(source: Source) : this(
bundleOf(SOURCE_ID_KEY to source.id),
)

View file

@ -21,6 +21,17 @@ class SettingsBrowseController : SettingsController() {
override fun setupPreferenceScreen(screen: PreferenceScreen) = screen.apply {
titleRes = R.string.browse
preferenceCategory {
titleRes = R.string.pref_category_general
switchPreference {
bindTo(preferences.pinsOnTop())
titleRes = R.string.pref_move_on_top
summaryRes = R.string.pref_move_on_top_summary
defaultValue = true
}
}
preferenceCategory {
titleRes = R.string.label_extensions

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/black"
android:pathData="M16,9V4l1,0c0.55,0 1,-0.45 1,-1v0c0,-0.55 -0.45,-1 -1,-1H7C6.45,2 6,2.45 6,3v0c0,0.55 0.45,1 1,1l1,0v5c0,1.66 -1.34,3 -3,3h0v2h5.97v7l1,1l1,-1v-7H19v-2h0C17.34,12 16,10.66 16,9z" />
</vector>

View file

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/black"
android:pathData="M14,4v5c0,1.12 0.37,2.16 1,3H9c0.65,-0.86 1,-1.9 1,-3V4H14M17,2H7C6.45,2 6,2.45 6,3c0,0.55 0.45,1 1,1c0,0 0,0 0,0l1,0v5c0,1.66 -1.34,3 -3,3v2h5.97v7l1,1l1,-1v-7H19v-2c0,0 0,0 0,0c-1.66,0 -3,-1.34 -3,-3V4l1,0c0,0 0,0 0,0c0.55,0 1,-0.45 1,-1C18,2.45 17.55,2 17,2L17,2z" />
</vector>

View file

@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingTop="8dp"
android:paddingBottom="@dimen/action_toolbar_list_padding"
tools:listitem="@layout/section_header_item" />
<eu.kanade.tachiyomi.widget.MaterialFastScroll
android:id="@+id/fast_scroller"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="end"
app:fastScrollerBubbleEnabled="false"
tools:visibility="visible" />
</FrameLayout>

View file

@ -23,13 +23,14 @@
android:id="@+id/title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:paddingStart="0dp"
android:paddingEnd="8dp"
android:textAppearance="?attr/textAppearanceBodyMedium"
app:layout_constraintBottom_toTopOf="@id/subtitle"
app:layout_constraintEnd_toStartOf="@+id/source_latest"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/image"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
@ -39,45 +40,15 @@
android:id="@+id/subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodySmall"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/source_latest"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/image"
app:layout_constraintTop_toBottomOf="@+id/title"
tools:text="English"
tools:visibility="visible" />
<Button
android:id="@+id/source_latest"
style="?attr/borderlessButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxHeight="48dp"
android:minWidth="0dp"
android:minHeight="48dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:text="@string/latest"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/pin"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<ImageButton
android:id="@+id/pin"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="8dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/action_pin"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_push_pin_outline_24dp"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -421,6 +421,8 @@
<!-- Browse section -->
<string name="pref_enable_automatic_extension_updates">Check for extension updates</string>
<string name="pref_search_pinned_sources_only">Only include pinned sources</string>
<string name="pref_move_on_top">Move pins on top</string>
<string name="pref_move_on_top_summary">Move up pins to top of the source list</string>
<!-- Backup section -->
<string name="pref_create_backup">Create backup</string>