1
0
mirror of https://github.com/TeamNewPipe/NewPipe synced 2026-06-02 10:42:11 +00:00

Merge pull request #12803 from HatakeKakashri/debug_screen_migration

Complete the debug screen migration to Compose
This commit is contained in:
Ida Delphine
2026-03-30 09:27:48 +01:00
committed by GitHub
22 changed files with 949 additions and 263 deletions
+6 -1
View File
@@ -271,9 +271,14 @@ dependencies {
implementation(libs.androidx.compose.ui.text) // Needed for parsing HTML to AnnotatedString
implementation(libs.androidx.compose.material.icons.extended)
// Jetpack navigatio3
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.viewmodel)
// Jetpack Compose related dependencies
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose)
// Coroutines interop
implementation(libs.kotlinx.coroutines.rx3)
@@ -1,20 +0,0 @@
package org.schabi.newpipe.settings;
import android.content.Intent;
import leakcanary.LeakCanary;
/**
* Build variant dependent (BVD) leak canary API implementation for the debug settings fragment.
* This class is loaded via reflection by
* {@link DebugSettingsFragment.DebugSettingsBVDLeakCanaryAPI}.
*/
@SuppressWarnings("unused") // Class is used but loaded via reflection
public class DebugSettingsBVDLeakCanary
implements DebugSettingsFragment.DebugSettingsBVDLeakCanaryAPI {
@Override
public Intent getNewLeakDisplayActivityIntent() {
return LeakCanary.INSTANCE.newLeakDisplayActivityIntent();
}
}
@@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.settings
import android.content.Intent
import leakcanary.LeakCanary.newLeakDisplayActivityIntent
/**
* Build variant dependent (BVD) leak canary API implementation for the debug settings fragment.
* This class is loaded via reflection by
* [DebugSettingsBVDLeakCanaryAPI].
*/
@Suppress("unused") // Class is used but loaded via reflection
class DebugSettingsBVDLeakCanary :
DebugSettingsBVDLeakCanaryAPI {
override fun getNewLeakDisplayActivityIntent(): Intent {
return newLeakDisplayActivityIntent()
}
}
@@ -0,0 +1,54 @@
/*
* SPDX-FileCopyrightText: 2017-2026 NewPipe contributors <https://newpipe.net>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.navigation
import androidx.navigation3.runtime.NavKey
import kotlinx.serialization.Serializable
@Serializable
sealed interface Screen : NavKey {
sealed interface Settings : Screen {
@Serializable
data object Home : Settings
@Serializable
data object Player : Settings
@Serializable
data object Behaviour : Settings
@Serializable
data object Download : Settings
@Serializable
data object LookFeel : Settings
@Serializable
data object HistoryCache : Settings
@Serializable
data object Content : Settings
@Serializable
data object Feed : Settings
@Serializable
data object Services : Settings
@Serializable
data object Language : Settings
@Serializable
data object BackupRestore : Settings
@Serializable
data object Updates : Settings
@Serializable
data object Debug : Settings
}
}
@@ -1,26 +0,0 @@
package org.schabi.newpipe.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import org.schabi.newpipe.R
import org.schabi.newpipe.settings.viewmodel.SettingsViewModel
import org.schabi.newpipe.ui.SwitchPreference
import org.schabi.newpipe.ui.theme.SizeTokens
@Composable
fun DebugScreen(viewModel: SettingsViewModel, modifier: Modifier = Modifier) {
val settingsLayoutRedesign by viewModel.settingsLayoutRedesign.collectAsState()
Column(modifier = modifier) {
SwitchPreference(
modifier = Modifier.padding(SizeTokens.SpacingExtraSmall),
R.string.settings_layout_redesign,
settingsLayoutRedesign,
viewModel::toggleSettingsLayoutRedesign
)
}
}
@@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.settings
import android.content.Intent
/**
* Build variant dependent (BVD) leak canary API.
* Why is LeakCanary not used directly? Because it can't be assured to be available.
*/
interface DebugSettingsBVDLeakCanaryAPI {
fun getNewLeakDisplayActivityIntent(): Intent
companion object {
const val IMPL_CLASS = "org.schabi.newpipe.settings.DebugSettingsBVDLeakCanary"
}
}
@@ -1,6 +1,11 @@
/*
* SPDX-FileCopyrightText: 2017-2025 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.settings;
import android.content.Intent;
import android.os.Bundle;
import androidx.preference.Preference;
@@ -88,15 +93,4 @@ public class DebugSettingsFragment extends BasePreferenceFragment {
return Optional.empty();
}
}
/**
* Build variant dependent (BVD) leak canary API for this fragment.
* Why is LeakCanary not used directly? Because it can't be assured
*/
public interface DebugSettingsBVDLeakCanaryAPI {
String IMPL_CLASS =
"org.schabi.newpipe.settings.DebugSettingsBVDLeakCanary";
Intent getNewLeakDisplayActivityIntent();
}
}
@@ -1,23 +0,0 @@
package org.schabi.newpipe.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import org.schabi.newpipe.R
import org.schabi.newpipe.ui.TextPreference
@Composable
fun SettingsScreen(
onSelectSettingOption: (SettingsScreenKey) -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
TextPreference(
title = R.string.settings_category_debug_title,
onClick = { onSelectSettingOption(SettingsScreenKey.DEBUG) }
)
HorizontalDivider(color = Color.Black)
}
}
@@ -1,85 +1,30 @@
/*
* SPDX-FileCopyrightText: 2017-2025 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.settings
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import dagger.hilt.android.AndroidEntryPoint
import org.schabi.newpipe.R
import org.schabi.newpipe.settings.viewmodel.SettingsViewModel
import org.schabi.newpipe.ui.Toolbar
import org.schabi.newpipe.ui.screens.settings.navigation.SettingsNavigation
import org.schabi.newpipe.ui.theme.AppTheme
const val SCREEN_TITLE_KEY = "SCREEN_TITLE_KEY"
@AndroidEntryPoint
class SettingsV2Activity : ComponentActivity() {
private val settingsViewModel: SettingsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()
var screenTitle by remember { mutableIntStateOf(SettingsScreenKey.ROOT.screenTitle) }
navController.addOnDestinationChangedListener { _, _, arguments ->
screenTitle =
arguments?.getInt(SCREEN_TITLE_KEY) ?: SettingsScreenKey.ROOT.screenTitle
}
AppTheme {
Scaffold(topBar = {
Toolbar(
title = stringResource(id = screenTitle),
hasSearch = true,
onSearchQueryChange = null // TODO: Add suggestions logic
)
}) { padding ->
NavHost(
navController = navController,
startDestination = SettingsScreenKey.ROOT.name,
modifier = Modifier.padding(padding)
) {
composable(
SettingsScreenKey.ROOT.name,
listOf(createScreenTitleArg(SettingsScreenKey.ROOT.screenTitle))
) {
SettingsScreen(onSelectSettingOption = { screen ->
navController.navigate(screen.name)
})
}
composable(
SettingsScreenKey.DEBUG.name,
listOf(createScreenTitleArg(SettingsScreenKey.DEBUG.screenTitle))
) {
DebugScreen(settingsViewModel)
}
}
}
SettingsNavigation(
onExitSettings = { finish() }
)
}
}
}
}
fun createScreenTitleArg(@StringRes screenTitle: Int) = navArgument(SCREEN_TITLE_KEY) {
defaultValue = screenTitle
}
enum class SettingsScreenKey(@StringRes val screenTitle: Int) {
ROOT(R.string.settings),
DEBUG(R.string.settings_category_debug_title)
}
@@ -1,40 +0,0 @@
package org.schabi.newpipe.settings.viewmodel
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import androidx.lifecycle.AndroidViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.schabi.newpipe.R
import org.schabi.newpipe.util.Localization
@HiltViewModel
class SettingsViewModel @Inject constructor(
@ApplicationContext context: Context,
private val preferenceManager: SharedPreferences
) : AndroidViewModel(context.applicationContext as Application) {
private var settingsLayoutRedesignPref: Boolean
get() = preferenceManager.getBoolean(
Localization.compatGetString(getApplication(), R.string.settings_layout_redesign_key),
false
)
set(value) {
preferenceManager.edit().putBoolean(
Localization.compatGetString(getApplication(), R.string.settings_layout_redesign_key),
value
).apply()
}
private val _settingsLayoutRedesign: MutableStateFlow<Boolean> =
MutableStateFlow(settingsLayoutRedesignPref)
val settingsLayoutRedesign = _settingsLayoutRedesign.asStateFlow()
fun toggleSettingsLayoutRedesign(newState: Boolean) {
_settingsLayoutRedesign.value = newState
settingsLayoutRedesignPref = newState
}
}
@@ -1,53 +1,48 @@
/*
* SPDX-FileCopyrightText: 2017-2025 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
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.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import org.schabi.newpipe.ui.theme.SizeTokens
@Composable
fun SwitchPreference(
modifier: Modifier = Modifier,
@StringRes title: Int,
title: String,
isChecked: Boolean,
onCheckedChange: (Boolean) -> Unit,
@StringRes summary: Int? = null
summary: String? = null,
enabled: Boolean = true
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = modifier.fillMaxWidth()
modifier = modifier
.fillMaxWidth()
.padding(SizeTokens.SpacingSmall)
) {
Column {
Text(
text = stringResource(id = title),
modifier = Modifier.padding(SizeTokens.SpacingExtraSmall),
style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Start
)
summary?.let {
Text(
text = stringResource(id = summary),
modifier = Modifier.padding(SizeTokens.SpacingExtraSmall),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Start
)
}
Column(
modifier = Modifier.weight(1f)
) {
TextBase(title = title, summary = summary, enabled = enabled)
}
Spacer(modifier = Modifier.width(SizeTokens.SpacingSmall))
Switch(checked = isChecked, onCheckedChange = onCheckedChange)
Spacer(modifier = Modifier.width(SizeTokens.SpacingExtraSmall))
Switch(
checked = isChecked,
onCheckedChange = onCheckedChange,
enabled = enabled
)
}
}
@@ -0,0 +1,52 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
/**
* A base composable that displays a title and an optional summary text. Used in settings preference
* items such as TextPreference and SwitchPreference.
*
* @param title the title text to display
* @param summary the optional summary text to display below the title
* @param enabled whether the text should be displayed in an enabled or disabled state
*/
@Composable
internal fun TextBase(
title: String,
summary: String?,
enabled: Boolean = true
) {
Column {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Start,
color = if (enabled) Color.Unspecified else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
summary?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Start,
color = if (enabled) Color.Unspecified else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
)
}
}
}
@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
@Composable
private fun TextBasePreview() {
TextBase("Debug", "Debug settings summary")
}
@@ -1,7 +1,12 @@
/*
* SPDX-FileCopyrightText: 2017-2025 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.ui
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -13,54 +18,41 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
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 androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import org.schabi.newpipe.ui.theme.SizeTokens
@Composable
fun TextPreference(
modifier: Modifier = Modifier,
@StringRes title: Int,
title: String,
@DrawableRes icon: Int? = null,
@StringRes summary: Int? = null,
onClick: () -> Unit
summary: String? = null,
onClick: () -> Unit,
enabled: Boolean = true
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
horizontalArrangement = Arrangement.Start,
modifier = modifier
.fillMaxWidth()
.padding(SizeTokens.SpacingSmall)
.defaultMinSize(minHeight = SizeTokens.SpaceMinSize)
.clickable { onClick() }
.clickable(enabled = enabled) { onClick() }
) {
icon?.let {
Icon(
painter = painterResource(id = icon),
contentDescription = "icon for $title preference"
contentDescription = null,
tint = if (enabled) Color.Unspecified else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
)
Spacer(modifier = Modifier.width(SizeTokens.SpacingSmall))
}
Column {
Text(
text = stringResource(id = title),
modifier = Modifier.padding(SizeTokens.SpacingExtraSmall),
style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Start
)
summary?.let {
Text(
text = stringResource(id = summary),
modifier = Modifier.padding(SizeTokens.SpacingExtraSmall),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Start
)
}
TextBase(title = title, summary = summary, enabled = enabled)
}
}
}
@@ -1,8 +1,20 @@
/*
* SPDX-FileCopyrightText: 2017-2025 NewPipe contributors <https://newpipe.net>
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.ui.components.common
import android.content.res.Configuration
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -10,11 +22,23 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import org.schabi.newpipe.R
import org.schabi.newpipe.ui.theme.AppTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -22,42 +46,148 @@ fun ScaffoldWithToolbar(
title: String,
onBackClick: () -> Unit,
actions: @Composable RowScope.() -> Unit = {},
hasSearch: Boolean = false,
onSearchQueryChange: ((String) -> List<String>)? = null,
onSearchAction: ((String) -> Unit)? = null,
searchPlaceholder: @Composable (() -> Unit)? = null,
content: @Composable (PaddingValues) -> Unit
) {
var isSearchActive by rememberSaveable { mutableStateOf(false) }
var query by rememberSaveable { mutableStateOf("") }
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = title) },
// TODO decide whether to use default colors instead
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
scrolledContainerColor = MaterialTheme.colorScheme.primaryContainer,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null
if (isSearchActive) {
SearchBar(
inputField = {
SearchBarDefaults.InputField(
query = query,
onQueryChange = { query = it },
onSearch = {
onSearchAction?.invoke(it)
isSearchActive = false
},
expanded = true,
onExpandedChange = { isSearchActive = it },
placeholder = searchPlaceholder ?: {
Text(stringResource(id = R.string.search))
},
leadingIcon = {
IconButton(onClick = {
isSearchActive = false
query = ""
}) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.navigate_back)
)
}
}
)
},
expanded = true,
onExpandedChange = { isSearchActive = it }
) {
val suggestions = onSearchQueryChange?.invoke(query) ?: emptyList()
if (suggestions.isNotEmpty()) {
Column(Modifier.fillMaxWidth()) {
suggestions.forEach { suggestionText ->
SearchSuggestionItem(text = suggestionText)
}
}
} else {
DefaultSearchNoResults()
}
},
actions = actions
)
}
} else {
TopAppBar(
title = { Text(text = title) },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
scrolledContainerColor = MaterialTheme.colorScheme.primaryContainer,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer
),
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.navigate_back)
)
}
},
actions = {
actions()
// existing actions
if (hasSearch) {
// Show search icon
IconButton(onClick = { isSearchActive = true }) {
Icon(
painter = painterResource(id = R.drawable.ic_search),
contentDescription = stringResource(id = R.string.search),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
)
}
},
content = content
)
}
@Composable
fun SearchSuggestionItem(text: String) {
// TODO: Add more components here to display all the required details of a search suggestion item.
Text(text = text)
}
@Composable
private fun DefaultSearchNoResults() {
Box(
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column {
Text(text = "╰(°●°╰)")
Text(text = stringResource(id = R.string.search_no_results))
}
}
}
@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ScaffoldWithToolbarPreview() {
ScaffoldWithToolbar(
title = "Example",
onBackClick = {},
content = {}
)
AppTheme {
ScaffoldWithToolbar(
title = "Example",
onBackClick = {},
hasSearch = true,
onSearchQueryChange = { query ->
if (query.isNotBlank()) {
listOf("Suggestion 1 for $query", "Suggestion 2 for $query")
} else {
emptyList()
}
},
onSearchAction = { query ->
println("Searching for: $query")
},
content = { paddingValues ->
Box(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("Screen Content")
}
}
)
}
}
@@ -0,0 +1,52 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.ui.screens.settings
import android.content.Context
import android.content.SharedPreferences
import androidx.annotation.StringRes
import androidx.core.content.edit
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.schabi.newpipe.util.Localization
/**
* Encapsulates the state and update logic for a boolean preference.
*
* Registers a [SharedPreferences.OnSharedPreferenceChangeListener] so the
* exposed [state] stays in sync even when the preference is changed externally
* (e.g. by another screen, a background service, or a data migration).
*
* @param keyResId The string resource ID for the preference key.
* @param defaultValue The default value of the preference.
* @param context The application context.
* @param preferenceManager The [SharedPreferences] manager.
*/
internal class BooleanPreference(
@StringRes keyResId: Int,
private val defaultValue: Boolean,
context: Context,
private val preferenceManager: SharedPreferences
) {
private val key = Localization.compatGetString(context, keyResId)
private val _state = MutableStateFlow(preferenceManager.getBoolean(key, defaultValue))
val state: StateFlow<Boolean> = _state.asStateFlow()
private val listener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, changedKey ->
if (changedKey == key) {
_state.value = prefs.getBoolean(key, defaultValue)
}
}
init {
preferenceManager.registerOnSharedPreferenceChangeListener(listener)
}
fun toggle(newValue: Boolean) {
preferenceManager.edit { putBoolean(key, newValue) }
}
}
@@ -0,0 +1,33 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.ui.screens.settings
import android.content.Context
import android.content.SharedPreferences
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import org.schabi.newpipe.R
@HiltViewModel
class SettingsViewModel @Inject constructor(
@ApplicationContext context: Context,
preferenceManager: SharedPreferences
) : ViewModel() {
private val settingsLayoutRedesignPref =
BooleanPreference(
R.string.settings_layout_redesign_key,
false,
context.applicationContext,
preferenceManager
)
val settingsLayoutRedesign = settingsLayoutRedesignPref.state
fun toggleSettingsLayoutRedesign(newValue: Boolean) = settingsLayoutRedesignPref.toggle(newValue)
}
@@ -0,0 +1,215 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.ui.screens.settings.debug
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import org.schabi.newpipe.R
import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.ErrorUtil
import org.schabi.newpipe.error.ErrorUtil.Companion.createNotification
import org.schabi.newpipe.error.UserAction
import org.schabi.newpipe.ui.SwitchPreference
import org.schabi.newpipe.ui.TextPreference
import org.schabi.newpipe.ui.components.common.ScaffoldWithToolbar
import org.schabi.newpipe.ui.screens.settings.SettingsViewModel
private const val DUMMY = "Dummy"
data class DebugScreenState(
val settingsLayoutRedesign: Boolean,
val isLeakCanaryAvailable: Boolean,
val allowHeapDumping: Boolean,
val allowDisposedExceptions: Boolean,
val showOriginalTimeAgo: Boolean,
val showCrashThePlayer: Boolean
)
data class DebugScreenActions(
val onBackClick: () -> Unit,
val onToggleAllowHeapDumping: (Boolean) -> Unit,
val onShowMemoryLeaksClick: () -> Unit,
val onToggleAllowDisposedExceptions: (Boolean) -> Unit,
val onToggleShowOriginalTimeAgo: (Boolean) -> Unit,
val onToggleShowCrashThePlayer: (Boolean) -> Unit,
val onCheckNewStreamsClick: () -> Unit,
val onCrashTheAppClick: () -> Unit,
val onShowErrorSnackbarClick: () -> Unit,
val onCreateErrorNotificationClick: () -> Unit,
val onToggleSettingsLayoutRedesign: (Boolean) -> Unit
)
@Composable
fun DebugScreen(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
debugViewModel: DebugSettingsViewModel = hiltViewModel(),
settingsViewModel: SettingsViewModel = hiltViewModel()
) {
val context = LocalContext.current
val state = DebugScreenState(
settingsLayoutRedesign = settingsViewModel.settingsLayoutRedesign.collectAsState().value,
isLeakCanaryAvailable = debugViewModel.isLeakCanaryAvailable.collectAsState().value,
allowHeapDumping = debugViewModel.allowHeapDumping.collectAsState().value,
allowDisposedExceptions = debugViewModel.allowDisposedExceptions.collectAsState().value,
showOriginalTimeAgo = debugViewModel.showOriginalTimeAgo.collectAsState().value,
showCrashThePlayer = debugViewModel.showCrashThePlayer.collectAsState().value
)
val actions = DebugScreenActions(
onBackClick = onBackClick,
onToggleAllowHeapDumping = debugViewModel::toggleAllowHeapDumping,
onShowMemoryLeaksClick = {
debugViewModel.getLeakDisplayActivityIntent()?.let {
context.startActivity(it)
}
},
onToggleAllowDisposedExceptions = debugViewModel::toggleAllowDisposedExceptions,
onToggleShowOriginalTimeAgo = debugViewModel::toggleShowOriginalTimeAgo,
onToggleShowCrashThePlayer = debugViewModel::toggleShowCrashThePlayer,
onCheckNewStreamsClick = debugViewModel::checkNewStreams,
onCrashTheAppClick = {
throw RuntimeException(DUMMY)
},
onShowErrorSnackbarClick = {
ErrorUtil.showUiErrorSnackbar(
context,
DUMMY,
RuntimeException(DUMMY)
)
},
onCreateErrorNotificationClick = {
createNotification(
context,
ErrorInfo(
RuntimeException(DUMMY),
UserAction.UI_ERROR,
DUMMY
)
)
},
onToggleSettingsLayoutRedesign = settingsViewModel::toggleSettingsLayoutRedesign
)
DebugScreenContent(
state = state,
actions = actions,
modifier = modifier
)
}
@Composable
fun DebugScreenContent(
state: DebugScreenState,
actions: DebugScreenActions,
modifier: Modifier = Modifier
) {
ScaffoldWithToolbar(
title = stringResource(id = R.string.settings_category_debug_title),
onBackClick = actions.onBackClick
) { paddingValues ->
Column(modifier = modifier.padding(paddingValues)) {
SwitchPreference(
title = stringResource(R.string.leakcanary),
summary = stringResource(
if (state.isLeakCanaryAvailable) {
R.string.enable_leak_canary_summary
} else {
R.string.leak_canary_not_available
}
),
isChecked = state.allowHeapDumping,
onCheckedChange = actions.onToggleAllowHeapDumping,
enabled = state.isLeakCanaryAvailable
)
TextPreference(
title = stringResource(R.string.show_memory_leaks),
summary = if (state.isLeakCanaryAvailable) {
null
} else {
stringResource(R.string.leak_canary_not_available)
},
onClick = actions.onShowMemoryLeaksClick,
enabled = state.isLeakCanaryAvailable
)
SwitchPreference(
title = stringResource(R.string.enable_disposed_exceptions_title),
summary = stringResource(R.string.enable_disposed_exceptions_summary),
isChecked = state.allowDisposedExceptions,
onCheckedChange = actions.onToggleAllowDisposedExceptions
)
SwitchPreference(
title = stringResource(R.string.show_original_time_ago_title),
summary = stringResource(R.string.show_original_time_ago_summary),
isChecked = state.showOriginalTimeAgo,
onCheckedChange = actions.onToggleShowOriginalTimeAgo
)
SwitchPreference(
title = stringResource(R.string.show_crash_the_player_title),
summary = stringResource(R.string.show_crash_the_player_summary),
isChecked = state.showCrashThePlayer,
onCheckedChange = actions.onToggleShowCrashThePlayer
)
TextPreference(
title = stringResource(R.string.check_new_streams),
onClick = actions.onCheckNewStreamsClick
)
TextPreference(
title = stringResource(R.string.crash_the_app),
onClick = actions.onCrashTheAppClick
)
TextPreference(
title = stringResource(R.string.show_error_snackbar),
onClick = actions.onShowErrorSnackbarClick
)
TextPreference(
title = stringResource(R.string.create_error_notification),
onClick = actions.onCreateErrorNotificationClick
)
SwitchPreference(
title = stringResource(R.string.settings_layout_redesign),
isChecked = state.settingsLayoutRedesign,
onCheckedChange = actions.onToggleSettingsLayoutRedesign
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun DebugScreenPreview() {
DebugScreenContent(
state = DebugScreenState(
settingsLayoutRedesign = false,
isLeakCanaryAvailable = true,
allowHeapDumping = false,
allowDisposedExceptions = true,
showOriginalTimeAgo = false,
showCrashThePlayer = true
),
actions = DebugScreenActions(
onBackClick = {},
onToggleAllowHeapDumping = {},
onShowMemoryLeaksClick = {},
onToggleAllowDisposedExceptions = {},
onToggleShowOriginalTimeAgo = {},
onToggleShowCrashThePlayer = {},
onCheckNewStreamsClick = {},
onCrashTheAppClick = {},
onShowErrorSnackbarClick = {},
onCreateErrorNotificationClick = {},
onToggleSettingsLayoutRedesign = {}
)
)
}
@@ -0,0 +1,82 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.ui.screens.settings.debug
import android.app.Application
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.schabi.newpipe.R
import org.schabi.newpipe.local.feed.notifications.NotificationWorker
import org.schabi.newpipe.settings.DebugSettingsBVDLeakCanaryAPI
import org.schabi.newpipe.ui.screens.settings.BooleanPreference
@HiltViewModel
class DebugSettingsViewModel @Inject constructor(
@ApplicationContext context: Context,
preferenceManager: SharedPreferences
) : ViewModel() {
private val application = context.applicationContext as Application
private val bvdLeakCanaryApi: DebugSettingsBVDLeakCanaryAPI? = runCatching {
Class.forName(DebugSettingsBVDLeakCanaryAPI.IMPL_CLASS)
.getDeclaredConstructor()
.newInstance() as DebugSettingsBVDLeakCanaryAPI
}.getOrNull()
private val _isLeakCanaryAvailable = MutableStateFlow(bvdLeakCanaryApi != null)
private val allowHeapDumpingPref = BooleanPreference(
R.string.allow_heap_dumping_key,
false,
context.applicationContext,
preferenceManager
)
private val allowDisposedExceptionsPref = BooleanPreference(
R.string.allow_disposed_exceptions_key,
false,
context.applicationContext,
preferenceManager
)
private val showOriginalTimeAgoPref = BooleanPreference(
R.string.show_original_time_ago_key,
false,
context.applicationContext,
preferenceManager
)
private val showCrashThePlayerPref = BooleanPreference(
R.string.show_crash_the_player_key,
false,
context.applicationContext,
preferenceManager
)
val isLeakCanaryAvailable = _isLeakCanaryAvailable.asStateFlow()
val allowHeapDumping = allowHeapDumpingPref.state
val allowDisposedExceptions = allowDisposedExceptionsPref.state
val showOriginalTimeAgo = showOriginalTimeAgoPref.state
val showCrashThePlayer = showCrashThePlayerPref.state
fun getLeakDisplayActivityIntent(): Intent? {
return bvdLeakCanaryApi?.getNewLeakDisplayActivityIntent()
}
fun toggleAllowHeapDumping(newValue: Boolean) = allowHeapDumpingPref.toggle(newValue)
fun toggleAllowDisposedExceptions(newValue: Boolean) = allowDisposedExceptionsPref.toggle(newValue)
fun toggleShowOriginalTimeAgo(newValue: Boolean) = showOriginalTimeAgoPref.toggle(newValue)
fun toggleShowCrashThePlayer(newValue: Boolean) = showCrashThePlayerPref.toggle(newValue)
fun checkNewStreams() {
NotificationWorker.runNow(application)
}
}
@@ -0,0 +1,131 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.ui.screens.settings.home
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import org.schabi.newpipe.BuildConfig
import org.schabi.newpipe.R
import org.schabi.newpipe.navigation.Screen
import org.schabi.newpipe.ui.TextPreference
import org.schabi.newpipe.ui.components.common.ScaffoldWithToolbar
@Composable
fun SettingsHomeScreen(
onNavigate: (Screen.Settings) -> Unit,
onBackClick: () -> Unit
) {
ScaffoldWithToolbar(
title = stringResource(id = R.string.settings),
onBackClick = onBackClick
) { paddingValues ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
item {
TextPreference(
title = stringResource(R.string.settings_category_player_title),
icon = R.drawable.ic_play_arrow,
onClick = { onNavigate(Screen.Settings.Player) }
)
}
item {
TextPreference(
title = stringResource(R.string.settings_category_player_behavior_title),
icon = R.drawable.ic_settings,
onClick = { onNavigate(Screen.Settings.Behaviour) }
)
}
item {
TextPreference(
title = stringResource(R.string.settings_category_downloads_title),
icon = R.drawable.ic_file_download,
onClick = { onNavigate(Screen.Settings.Download) }
)
}
item {
TextPreference(
title = stringResource(R.string.settings_category_look_and_feel_title),
icon = R.drawable.ic_palette,
onClick = { onNavigate(Screen.Settings.LookFeel) }
)
}
item {
TextPreference(
title = stringResource(R.string.settings_category_history_title),
icon = R.drawable.ic_history,
onClick = { onNavigate(Screen.Settings.HistoryCache) }
)
}
item {
TextPreference(
title = stringResource(R.string.settings_category_content_title),
icon = R.drawable.ic_tv,
onClick = { onNavigate(Screen.Settings.Content) }
)
}
item {
TextPreference(
title = stringResource(R.string.settings_category_feed_title),
icon = R.drawable.ic_rss_feed,
onClick = { onNavigate(Screen.Settings.Feed) }
)
}
item {
TextPreference(
title = stringResource(R.string.settings_category_services_title),
icon = R.drawable.ic_subscriptions,
onClick = { onNavigate(Screen.Settings.Services) }
)
}
item {
TextPreference(
title = stringResource(R.string.settings_category_language_title),
icon = R.drawable.ic_language,
onClick = { onNavigate(Screen.Settings.Language) }
)
}
item {
TextPreference(
title = stringResource(R.string.settings_category_backup_restore_title),
icon = R.drawable.ic_backup,
onClick = { onNavigate(Screen.Settings.BackupRestore) }
)
}
// Show Updates only on release builds
if (!BuildConfig.DEBUG) {
item {
TextPreference(
title = stringResource(R.string.settings_category_updates_title),
icon = R.drawable.ic_newpipe_update,
onClick = { onNavigate(Screen.Settings.Updates) }
)
}
}
// Show Debug only on debug builds
if (BuildConfig.DEBUG) {
item {
TextPreference(
title = stringResource(R.string.settings_category_debug_title),
icon = R.drawable.ic_bug_report,
onClick = { onNavigate(Screen.Settings.Debug) }
)
}
}
}
}
}
@Preview
@Composable
private fun SettingsHomeScreenPreview() = SettingsHomeScreen(onNavigate = {}, onBackClick = {})
@@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: 2025-2026 NewPipe e.V. <https://newpipe-ev.de>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package org.schabi.newpipe.ui.screens.settings.navigation
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import org.schabi.newpipe.R
import org.schabi.newpipe.navigation.Screen
import org.schabi.newpipe.ui.screens.settings.debug.DebugScreen
import org.schabi.newpipe.ui.screens.settings.home.SettingsHomeScreen
@Composable
fun SettingsNavigation(onExitSettings: () -> Unit) {
val backStack = rememberNavBackStack(Screen.Settings.Home)
val handleBack: () -> Unit = {
if (backStack.size > 1) {
backStack.removeLastOrNull()
} else {
onExitSettings()
}
}
NavDisplay(
backStack = backStack,
onBack = handleBack,
entryProvider = entryProvider {
entry<Screen.Settings.Home> {
SettingsHomeScreen(
onNavigate = { screen -> backStack.add(screen) },
onBackClick = handleBack
)
}
entry<Screen.Settings.Player> { Text(stringResource(id = R.string.settings_category_player_title)) }
entry<Screen.Settings.Behaviour> { Text(stringResource(id = R.string.settings_category_player_behavior_title)) }
entry<Screen.Settings.Download> { Text(stringResource(id = R.string.settings_category_downloads_title)) }
entry<Screen.Settings.LookFeel> { Text(stringResource(id = R.string.settings_category_look_and_feel_title)) }
entry<Screen.Settings.HistoryCache> { Text(stringResource(id = R.string.settings_category_history_title)) }
entry<Screen.Settings.Content> { Text(stringResource(id = R.string.settings_category_content_title)) }
entry<Screen.Settings.Feed> { Text(stringResource(id = R.string.settings_category_feed_title)) }
entry<Screen.Settings.Services> { Text(stringResource(id = R.string.settings_category_services_title)) }
entry<Screen.Settings.Language> { Text(stringResource(id = R.string.settings_category_language_title)) }
entry<Screen.Settings.BackupRestore> { Text(stringResource(id = R.string.settings_category_backup_restore_title)) }
entry<Screen.Settings.Updates> { Text(stringResource(id = R.string.settings_category_updates_title)) }
entry<Screen.Settings.Debug> {
DebugScreen(onBackClick = { backStack.removeLastOrNull() })
}
},
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator()
)
)
}
+5
View File
@@ -154,11 +154,15 @@
<string name="settings_category_video_audio_title">Video and audio</string>
<string name="settings_category_history_title">History and cache</string>
<string name="settings_category_appearance_title">Appearance</string>
<string name="settings_category_look_and_feel_title">Look and feel</string>
<string name="settings_category_debug_title">Debug</string>
<string name="settings_category_updates_title">Updates</string>
<string name="settings_category_player_notification_title">Player notification</string>
<string name="settings_category_player_notification_summary">Configure current playing stream notification</string>
<string name="settings_category_backup_restore_title">Backup and restore</string>
<string name="settings_category_content_title">Content</string>
<string name="settings_category_services_title">Services</string>
<string name="settings_category_language_title">Language</string>
<string name="background_player_playing_toast">Playing in background</string>
<string name="popup_playing_toast">Playing in popup mode</string>
<string name="content">Content</string>
@@ -904,4 +908,5 @@
<string name="youtube_player_http_403">HTTP error 403 received from server while playing, likely caused by an IP ban or streaming URL deobfuscation issues</string>
<string name="sign_in_confirm_not_bot_error">%1$s refused to provide data, asking for a login to confirm the requester is not a bot.\n\nYour IP might have been temporarily banned by %1$s, you can wait some time or switch to a different IP (for example by turning on/off a VPN, or by switching from WiFi to mobile data).</string>
<string name="unsupported_content_in_country">This content is not available for the currently selected content country.\n\nChange your selection from \"Settings > Content > Default content country\".</string>
<string name="navigate_back">Navigate back</string>
</resources>
+6 -2
View File
@@ -24,6 +24,7 @@ exoplayer = "2.19.1"
fragment-compose = "1.8.9"
groupie = "2.10.1"
hilt = "2.58" # Newer version requires AGP 9
hilt-navigation-compose = "1.3.0"
jsoup = "1.21.2"
junit = "4.13.2"
junit-ext = "1.3.0"
@@ -39,7 +40,7 @@ markwon = "4.6.2"
material = "1.11.0" # TODO: update to newer version after bug is fixed. See https://github.com/TeamNewPipe/NewPipe/pull/13018
media = "1.7.1"
mockitoCore = "5.21.0"
navigation-compose = "2.8.3"
nav3Core = "1.0.1"
okhttp = "5.3.2"
paging-compose = "3.3.2"
phoenix = "3.0.0"
@@ -89,12 +90,15 @@ androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayo
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core" }
androidx-documentfile = { module = "androidx.documentfile:documentfile", version.ref = "documentfile" }
androidx-fragment-compose = { module = "androidx.fragment:fragment-compose", version.ref = "fragment-compose" }
androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-navigation-compose" }
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "junit-ext" }
androidx-lifecycle-livedata = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose" }
androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
androidx-media = { module = "androidx.media:media", version.ref = "media" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" }
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
androidx-navigation3-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging-compose" }
androidx-preference = { module = "androidx.preference:preference", version.ref = "preference" }
androidx-recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" }