tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit ce9d380b6de880d141d0a3a7974606eb8a6ceaf2
parent b39f79e091c591d768df554acb14ae8268be1b86
Author: Gabriel Luong <gabriel.luong@gmail.com>
Date:   Fri, 19 Dec 2025 06:22:22 +0000

Bug 2005393 - Add a Firefox Labs screen in Settings r=android-reviewers,android-l10n-reviewers,flod,petru

- Reuses the designs of the existing settings in the M3 specs.
- Empty state: https://www.figma.com/design/ctk1Pw1TBxUwVgTTOvjHb4/2025-Android-Fundamentals?node-id=623-21574&m=dev
- Settings: https://www.figma.com/design/ctk1Pw1TBxUwVgTTOvjHb4/2025-Android-Fundamentals?node-id=991-21322&m=dev

Differential Revision: https://phabricator.services.mozilla.com/D275935

Diffstat:
Mmobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt | 7+++++++
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/labs/FirefoxLabsFragment.kt | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/labs/LabsFeature.kt | 29+++++++++++++++++++++++++++++
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/labs/middleware/LabsMiddleware.kt | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/labs/store/LabsAction.kt | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/labs/store/LabsState.kt | 48++++++++++++++++++++++++++++++++++++++++++++++++
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/labs/store/LabsStore.kt | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/labs/ui/FirefoxLabsScreen.kt | 382+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mmobile/android/fenix/app/src/main/res/navigation/nav_graph.xml | 10++++++++++
Mmobile/android/fenix/app/src/main/res/values/preference_keys.xml | 1+
Mmobile/android/fenix/app/src/main/res/values/static_strings.xml | 31+++++++++++++++++++++++++++++++
Mmobile/android/fenix/app/src/main/res/xml/preferences.xml | 6++++++
Amobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/labs/middleware/LabsMiddlewareTest.kt | 127+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/labs/store/LabsStoreTest.kt | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
14 files changed, 1108 insertions(+), 0 deletions(-)

diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/SettingsFragment.kt @@ -487,6 +487,10 @@ class SettingsFragment : PreferenceFragmentCompat() { SettingsFragmentDirections.actionSettingsFragmentToOpenDownloadsSettingsFragment() } + resources.getString(R.string.pref_key_firefox_labs) -> { + SettingsFragmentDirections.actionSettingsFragmentToFirefoxLabsFragment() + } + resources.getString(R.string.pref_key_sync_debug) -> { SettingsFragmentDirections.actionSettingsFragmentToSyncDebugFragment() } @@ -576,6 +580,9 @@ class SettingsFragment : PreferenceFragmentCompat() { findPreference<Preference>( getPreferenceKey(R.string.pref_key_sync_debug), )?.isVisible = showSecretDebugMenuThisSession + findPreference<Preference>( + getPreferenceKey(R.string.pref_key_firefox_labs), + )?.isVisible = enableFirefoxLabs preferenceStartProfiler?.isVisible = showSecretDebugMenuThisSession && (components.core.engine.profiler?.isProfilerActive() != null) } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/labs/FirefoxLabsFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/labs/FirefoxLabsFragment.kt @@ -0,0 +1,74 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.labs + +import android.content.Intent +import android.os.Bundle +import android.os.Process +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.compose.content +import androidx.navigation.fragment.findNavController +import mozilla.components.lib.state.helpers.StoreProvider.Companion.fragmentStore +import org.mozilla.fenix.ext.hideToolbar +import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.settings.labs.middleware.LabsMiddleware +import org.mozilla.fenix.settings.labs.store.LabsState +import org.mozilla.fenix.settings.labs.store.LabsStore +import org.mozilla.fenix.settings.labs.ui.FirefoxLabsScreen +import org.mozilla.fenix.theme.FirefoxTheme + +/** + * Fragment for displaying the Firefox Labs screen. + */ +class FirefoxLabsFragment : Fragment() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + hideToolbar() + } + + private val labsStore by fragmentStore( + initialState = LabsState.INITIAL, + ) { + LabsStore( + initialState = it, + middleware = listOf( + LabsMiddleware( + settings = requireContext().settings(), + onRestart = ::restartFenix, + ), + ), + ) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View = content { + FirefoxTheme { + FirefoxLabsScreen( + store = labsStore, + onNavigationIconClick = { + this@FirefoxLabsFragment.findNavController().popBackStack() + }, + ) + } + } + + private fun restartFenix() { + val context = activity?.applicationContext + context?.startActivity( + Intent.makeRestartActivityTask( + context.packageManager.getLaunchIntentForPackage(context.packageName)?.component, + ), + ) + // Kill the existing process to ensure we get a clean start of the application + Process.killProcess(Process.myPid()) + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/labs/LabsFeature.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/labs/LabsFeature.kt @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.labs + +import androidx.annotation.StringRes + +/** + * Value type that represents a labs feature. + * + * @property key The [FeatureKey] of the feature. + * @property name The string resource ID of the feature name. + * @property description The string resource ID of the feature description. + * @property enabled Whether or not the feature is enabled. + */ +data class LabsFeature( + val key: FeatureKey, + @param:StringRes val name: Int, + @param:StringRes val description: Int, + val enabled: Boolean, +) + +/** + * Enum that represents a labs feature. + */ +enum class FeatureKey { + HOMEPAGE_AS_A_NEW_TAB, +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/labs/middleware/LabsMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/labs/middleware/LabsMiddleware.kt @@ -0,0 +1,104 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.labs.middleware + +import android.content.SharedPreferences +import androidx.core.content.edit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.MiddlewareContext +import mozilla.components.lib.state.Store +import org.mozilla.fenix.R +import org.mozilla.fenix.settings.labs.FeatureKey +import org.mozilla.fenix.settings.labs.LabsFeature +import org.mozilla.fenix.settings.labs.store.LabsAction +import org.mozilla.fenix.settings.labs.store.LabsState +import org.mozilla.fenix.utils.Settings + +/** + * [Middleware] implementation for handling [LabsAction] and managing the [LabsState] for the + * Firefox Labs screen. + * + * @param settings An instance of [Settings] to read and write to the [SharedPreferences] + * properties. + * @param onRestart Callback invoked to restart the application. + * @param scope [CoroutineScope] used to launch coroutines. + */ +class LabsMiddleware( + private val settings: Settings, + private val onRestart: () -> Unit, + private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO), +) : Middleware<LabsState, LabsAction> { + + override fun invoke( + context: MiddlewareContext<LabsState, LabsAction>, + next: (LabsAction) -> Unit, + action: LabsAction, + ) { + when (action) { + is LabsAction.InitAction -> initialize(store = context.store) + is LabsAction.RestartApplication -> restartApplication() + is LabsAction.RestoreDefaults -> restoreDefaults(store = context.store) + is LabsAction.ToggleFeature -> toggleFeature( + store = context.store, + feature = action.feature, + ) + + else -> Unit + } + + next(action) + } + + private fun initialize( + store: Store<LabsState, LabsAction>, + ) = scope.launch { + val features = listOf( + LabsFeature( + key = FeatureKey.HOMEPAGE_AS_A_NEW_TAB, + name = R.string.firefox_labs_homepage_as_a_new_tab, + description = R.string.firefox_labs_homepage_as_a_new_tab_description, + enabled = settings.enableHomepageAsNewTab, + ), + ) + + store.dispatch(LabsAction.UpdateFeatures(features)) + } + + private fun toggleFeature( + store: Store<LabsState, LabsAction>, + feature: LabsFeature, + ) = scope.launch { + setFeatureEnabled(key = feature.key, enabled = !feature.enabled) + store.dispatch(LabsAction.RestartApplication) + } + + private fun restoreDefaults( + store: Store<LabsState, LabsAction>, + ) = scope.launch { + for (key in FeatureKey.entries) { + setFeatureEnabled(key = key, enabled = false) + } + + store.dispatch(LabsAction.RestartApplication) + } + + private fun setFeatureEnabled(key: FeatureKey, enabled: Boolean) = scope.launch { + when (key) { + FeatureKey.HOMEPAGE_AS_A_NEW_TAB -> { + settings.enableHomepageAsNewTab = enabled + } + } + } + + private fun restartApplication() = scope.launch { + settings.preferences.edit { + commit() + } + onRestart() + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/labs/store/LabsAction.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/labs/store/LabsAction.kt @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.labs.store + +import mozilla.components.lib.state.Action +import org.mozilla.fenix.settings.labs.LabsFeature + +/** + * Actions to dispatch through the [LabsStore] to modify the [LabsState]. + */ +sealed class LabsAction : Action { + + /** + * [LabsAction] dispatched to indicate that the store is initialized and ready to use. + * This action is dispatched automatically before any other action is processed. + * Its main purpose is to trigger initialization logic in middlewares. + */ + data object InitAction : LabsAction() + + /** + * [LabsAction] dispatched when the list of features is updated. + * + * @property features The new list of [LabsFeature] to store. + */ + data class UpdateFeatures(val features: List<LabsFeature>) : LabsAction() + + /** + * [LabsAction] dispatched when a feature is toggled. + * + * @property feature The [LabsFeature] to toggle. + */ + data class ToggleFeature(val feature: LabsFeature) : LabsAction() + + /** + * [LabsAction] dispatched to restore the default settings without any lab features enabled. + */ + data object RestoreDefaults : LabsAction() + + /** + * [LabsAction] dispatched to restart the application. + */ + data object RestartApplication : LabsAction() + + /** + * [LabsAction] dispatched to show the dialog for toggling a [LabsFeature]. + * + * @property feature The [LabsFeature] that will be toggled. + */ + data class ShowToggleFeatureDialog(val feature: LabsFeature) : LabsAction() + + /** + * [LabsAction] dispatched to show the dialog for restoring all the [LabsFeature]s to their + * default disabled state. + */ + data object ShowRestoreDefaultsDialog : LabsAction() + + /** + * [LabsAction] dispatched to close the current dialog. + */ + data object CloseDialog : LabsAction() +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/labs/store/LabsState.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/labs/store/LabsState.kt @@ -0,0 +1,48 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.labs.store + +import mozilla.components.lib.state.State +import org.mozilla.fenix.settings.labs.LabsFeature + +/** + * Value type that represents the state of the Labs screen. + * + * @property labsFeatures A list of [LabsFeature]s to display. + * @property dialogState The current dialog being displayed. + */ +data class LabsState( + val labsFeatures: List<LabsFeature>, + val dialogState: DialogState, +) : State { + companion object { + val INITIAL = LabsState( + labsFeatures = emptyList(), + dialogState = DialogState.Closed, + ) + } +} + +/** + * Represents the dialog state of the Firefox Labs screen. + */ +sealed interface DialogState { + /** + * The dialog for toggling a [LabsFeature] on or off. + * + * @property feature The [LabsFeature] being toggled. + */ + data class ToggleFeature(val feature: LabsFeature) : DialogState + + /** + * The dialog for restoring all [LabsFeature]s to their default disabled state. + */ + object RestoreDefaults : DialogState + + /** + * No dialog is being shown. + */ + object Closed : DialogState +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/labs/store/LabsStore.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/labs/store/LabsStore.kt @@ -0,0 +1,66 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.labs.store + +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.Store + +/** + * The [Store] for holding the [LabsState] and applying [LabsAction]s. + */ +class LabsStore( + initialState: LabsState, + middleware: List<Middleware<LabsState, LabsAction>> = listOf(), +) : Store<LabsState, LabsAction>( + initialState = initialState, + reducer = ::reducer, + middleware = middleware, +) { + init { + dispatch(LabsAction.InitAction) + } +} + +private fun reducer(state: LabsState, action: LabsAction): LabsState { + return when (action) { + is LabsAction.InitAction, + is LabsAction.RestartApplication, + -> state + + is LabsAction.UpdateFeatures -> state.copy( + labsFeatures = action.features, + ) + + is LabsAction.RestoreDefaults -> state.copy( + labsFeatures = state.labsFeatures.map { + it.copy(enabled = false) + }, + dialogState = DialogState.Closed, + ) + + is LabsAction.ToggleFeature -> state.copy( + labsFeatures = state.labsFeatures.map { + if (it.key == action.feature.key) { + it.copy(enabled = !it.enabled) + } else { + it + } + }, + dialogState = DialogState.Closed, + ) + + is LabsAction.ShowToggleFeatureDialog -> state.copy( + dialogState = DialogState.ToggleFeature(action.feature), + ) + + is LabsAction.ShowRestoreDefaultsDialog -> state.copy( + dialogState = DialogState.RestoreDefaults, + ) + + is LabsAction.CloseDialog -> state.copy( + dialogState = DialogState.Closed, + ) + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/labs/ui/FirefoxLabsScreen.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/settings/labs/ui/FirefoxLabsScreen.kt @@ -0,0 +1,382 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.labs.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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 androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import mozilla.components.compose.base.annotation.FlexibleWindowLightDarkPreview +import mozilla.components.compose.base.button.FilledButton +import mozilla.components.compose.base.button.IconButton +import mozilla.components.compose.base.button.TextButton +import mozilla.components.compose.base.utils.BackInvokedHandler +import mozilla.components.lib.state.ext.observeAsState +import org.mozilla.fenix.R +import org.mozilla.fenix.compose.list.SwitchListItem +import org.mozilla.fenix.settings.labs.FeatureKey +import org.mozilla.fenix.settings.labs.LabsFeature +import org.mozilla.fenix.settings.labs.store.DialogState +import org.mozilla.fenix.settings.labs.store.LabsAction +import org.mozilla.fenix.settings.labs.store.LabsState +import org.mozilla.fenix.settings.labs.store.LabsStore +import org.mozilla.fenix.theme.FirefoxTheme +import org.mozilla.fenix.theme.Theme +import mozilla.components.ui.icons.R as iconsR + +/** + * Firefox Labs screen that displays a list of experimental features that can be opted into. + * + * @param store The [LabsStore] used to observe the screen state and dispatch actions. + * @param onNavigationIconClick Callback invoked when the navigation icon is clicked. + */ +@Composable +fun FirefoxLabsScreen( + store: LabsStore, + onNavigationIconClick: () -> Unit, +) { + val labsFeatures by store.observeAsState(initialValue = store.state.labsFeatures) { state -> + state.labsFeatures + } + + BackInvokedHandler { + onNavigationIconClick() + } + + Scaffold( + topBar = { + FirefoxLabsTopAppBar( + onNavigationIconClick = onNavigationIconClick, + ) + }, + ) { paddingValues -> + if (labsFeatures.isEmpty()) { + EmptyState(modifier = Modifier.padding(paddingValues)) + } else { + FirefoxLabsScreenContent( + labsFeatures = labsFeatures, + paddingValues = paddingValues, + onToggleFeature = { feature -> store.dispatch(LabsAction.ShowToggleFeatureDialog(feature)) }, + onRestoreDefaultsButtonClick = { store.dispatch(LabsAction.ShowRestoreDefaultsDialog) }, + ) + } + } + + FirefoxLabsDialog(store = store) +} + +@Composable +private fun FirefoxLabsScreenContent( + labsFeatures: List<LabsFeature>, + paddingValues: PaddingValues, + onToggleFeature: (LabsFeature) -> Unit, + onRestoreDefaultsButtonClick: () -> Unit, +) { + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize(), + ) { + item { + Text( + text = String.format( + stringResource(R.string.firefox_labs_experimental_description), + stringResource(R.string.app_name), + ), + modifier = Modifier.padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 16.dp), + style = FirefoxTheme.typography.body1, + ) + } + + items(labsFeatures) { feature -> + SwitchListItem( + label = stringResource(id = feature.name), + checked = feature.enabled, + description = stringResource(id = feature.description), + maxDescriptionLines = Int.MAX_VALUE, + showSwitchAfter = true, + onClick = { onToggleFeature(feature) }, + ) + } + + item { + FilledButton( + text = stringResource(R.string.firefox_labs_restore_default_button_text), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 24.dp), + onClick = onRestoreDefaultsButtonClick, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FirefoxLabsTopAppBar(onNavigationIconClick: () -> Unit) { + TopAppBar( + title = { + Text( + text = stringResource(R.string.firefox_labs_title), + style = FirefoxTheme.typography.headline5, + ) + }, + navigationIcon = { + IconButton( + onClick = onNavigationIconClick, + contentDescription = null, + ) { + Icon( + painter = painterResource(iconsR.drawable.mozac_ic_back_24), + contentDescription = null, + ) + } + }, + windowInsets = WindowInsets( + top = 0.dp, + bottom = 0.dp, + ), + ) +} + +@Composable +private fun EmptyState(modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(R.drawable.ic_onboarding_marketing_redesign), + contentDescription = null, + ) + + Spacer(modifier = Modifier.height(FirefoxTheme.layout.space.static200)) + + Text( + text = stringResource(id = R.string.firefox_labs_no_labs_available_description), + color = MaterialTheme.colorScheme.onSurface, + style = FirefoxTheme.typography.headline6, + ) + + Spacer(modifier = Modifier.height(FirefoxTheme.layout.space.static600)) + } +} + +@Composable +private fun FirefoxLabsDialog(store: LabsStore) { + val dialogState by store.observeAsState(initialValue = store.state.dialogState) { state -> + state.dialogState + } + + when (val currentDialog = dialogState) { + is DialogState.ToggleFeature -> { + ToggleFeatureDialog( + featureEnabled = currentDialog.feature.enabled, + onConfirm = { + store.dispatch(LabsAction.ToggleFeature(feature = currentDialog.feature)) + }, + onDismiss = { + store.dispatch(LabsAction.CloseDialog) + }, + ) + } + + is DialogState.RestoreDefaults -> { + RestoreDefaultsDialog( + onConfirm = { + store.dispatch(LabsAction.RestoreDefaults) + }, + onDismiss = { + store.dispatch(LabsAction.CloseDialog) + }, + ) + } + + DialogState.Closed -> {} + } +} + +@Composable +private fun ToggleFeatureDialog( + featureEnabled: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + text = stringResource(R.string.firefox_labs_dialog_restart_button), + onClick = onConfirm, + ) + }, + dismissButton = { + TextButton( + text = stringResource(R.string.firefox_labs_dialog_cancel_button), + onClick = onDismiss, + ) + }, + title = { + Text( + text = if (featureEnabled) { + stringResource(R.string.firefox_labs_disable_feature_dialog_title) + } else { + stringResource(R.string.firefox_labs_enable_feature_dialog_title) + }, + style = FirefoxTheme.typography.headline5, + ) + }, + text = { + Text( + text = String.format( + stringResource(R.string.firefox_labs_enable_feature_dialog_message), + stringResource(R.string.app_name), + ), + style = FirefoxTheme.typography.body2, + ) + }, + ) +} + +@Composable +private fun RestoreDefaultsDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + text = stringResource(R.string.firefox_labs_dialog_restart_button), + onClick = onConfirm, + ) + }, + dismissButton = { + TextButton( + text = stringResource(R.string.firefox_labs_dialog_cancel_button), + onClick = onDismiss, + ) + }, + title = { + Text( + text = stringResource(R.string.firefox_labs_restore_defaults_dialog_title), + style = FirefoxTheme.typography.headline5, + ) + }, + text = { + Text( + text = String.format( + stringResource(R.string.firefox_labs_restore_defaults_dialog_message), + stringResource(R.string.app_name), + ), + style = FirefoxTheme.typography.body2, + ) + }, + ) +} + +private class FirefoxLabsScreenPreviewProvider : PreviewParameterProvider<List<LabsFeature>> { + override val values: Sequence<List<LabsFeature>> + get() { + val sequenceOf = sequenceOf( + listOf( + LabsFeature( + key = FeatureKey.HOMEPAGE_AS_A_NEW_TAB, + name = R.string.firefox_labs_homepage_as_a_new_tab, + description = R.string.firefox_labs_homepage_as_a_new_tab_description, + enabled = true, + ), + ), + emptyList(), + ) + return sequenceOf + } +} + +@Composable +@FlexibleWindowLightDarkPreview +private fun FirefoxLabsScreenPreview( + @PreviewParameter(FirefoxLabsScreenPreviewProvider::class) labsFeatures: List<LabsFeature>, +) { + FirefoxTheme { + FirefoxLabsScreen( + store = LabsStore( + initialState = LabsState( + labsFeatures = labsFeatures, + dialogState = DialogState.Closed, + ), + ), + onNavigationIconClick = {}, + ) + } +} + +@Composable +@Preview +private fun FirefoxLabsScreenPrivatePreview( + @PreviewParameter(FirefoxLabsScreenPreviewProvider::class) labsFeatures: List<LabsFeature>, +) { + FirefoxTheme(theme = Theme.Private) { + FirefoxLabsScreen( + store = LabsStore( + initialState = LabsState( + labsFeatures = labsFeatures, + dialogState = DialogState.Closed, + ), + ), + onNavigationIconClick = {}, + ) + } +} + +@Composable +@PreviewLightDark +private fun ToggleFeatureDialogPreview() { + FirefoxTheme { + ToggleFeatureDialog( + featureEnabled = true, + onConfirm = {}, + onDismiss = {}, + ) + } +} + +@Composable +@PreviewLightDark +private fun RestoreDefaultsDialogPreview() { + FirefoxTheme { + RestoreDefaultsDialog( + onConfirm = {}, + onDismiss = {}, + ) + } +} diff --git a/mobile/android/fenix/app/src/main/res/navigation/nav_graph.xml b/mobile/android/fenix/app/src/main/res/navigation/nav_graph.xml @@ -756,6 +756,13 @@ app:popEnterAnim="@anim/slide_in_left" app:popExitAnim="@anim/slide_out_right" /> <action + android:id="@+id/action_settingsFragment_to_firefoxLabsFragment" + app:destination="@id/firefoxLabsFragment" + app:enterAnim="@anim/slide_in_right" + app:exitAnim="@anim/slide_out_left" + app:popEnterAnim="@anim/slide_in_left" + app:popExitAnim="@anim/slide_out_right" /> + <action android:id="@+id/action_settingsFragment_to_linkSharingFragment" app:destination="@id/linkSharingFragment" app:enterAnim="@anim/slide_in_right" @@ -1062,6 +1069,9 @@ app:nullable="true" /> </fragment> <fragment + android:id="@+id/firefoxLabsFragment" + android:name="org.mozilla.fenix.settings.labs.FirefoxLabsFragment" /> + <fragment android:id="@+id/linkSharingFragment" android:name="org.mozilla.fenix.settings.LinkSharingFragment" /> <fragment diff --git a/mobile/android/fenix/app/src/main/res/values/preference_keys.xml b/mobile/android/fenix/app/src/main/res/values/preference_keys.xml @@ -16,6 +16,7 @@ <string name="pref_key_accessibility_font_scale" translatable="false">pref_key_accessibility_font_scale</string> <string name="pref_key_accessibility_force_enable_zoom" translatable="false">pref_key_accessibility_force_enable_zoom</string> <string name="pref_key_advanced" translatable="false">pref_key_advanced</string> + <string name="pref_key_firefox_labs" translatable="false">pref_key_firefox_labs</string> <string name="pref_key_language" translatable="false">pref_key_language</string> <string name="pref_key_translation" translatable="false">pref_key_translation</string> <string name="pref_key_data_choices" translatable="false">pref_key_data_choices</string> diff --git a/mobile/android/fenix/app/src/main/res/values/static_strings.xml b/mobile/android/fenix/app/src/main/res/values/static_strings.xml @@ -269,4 +269,35 @@ <!-- Label for enabling Relay email masks --> <string name="preferences_enable_relay_email_masks">Enable Relay email masks</string> + + <!-- Firefox Labs --> + <!-- Firefox Labs is the name of a screen in Settings to allow users to learn about + experimental and in-development features, and turn those features on and off. + The "Labs" portion can be localized, "Firefox" must be treated as a brand and kept + in English. --> + <string name="firefox_labs_title" tools:ignore="BrandUsage">Firefox Labs</string> + <!-- Description text displayed in the Firefox Labs screen. %s is the name of the app (for example "Firefox"). --> + <string name="firefox_labs_experimental_description">Give our experimental features a try! They’re in development and evolving, which could impact how %s works.</string> + <!-- Button text for restoring the default settings by turning off any experimental features in the Firefox Labs screen. --> + <string name="firefox_labs_restore_default_button_text">Restore Defaults</string> + <!-- Text displayed when no experimental features are available in the Firefox Labs screen. --> + <string name="firefox_labs_no_labs_available_description">No experimental features available</string> + <!-- The name of an experimental feature displayed in theFirefox Labs screen. This features allows the user to open the homepage as a new tab. --> + <string name="firefox_labs_homepage_as_a_new_tab">Homepage as a New Tab</string> + <!-- The description of the "Homepage as a New Tab" feature in the Firefox Labs screen. --> + <string name="firefox_labs_homepage_as_a_new_tab_description">With this feature enabled, Homepage will behave as a tab.</string> + <!-- The title used in a confirmation dialog when toggling on an experimental feature. --> + <string name="firefox_labs_enable_feature_dialog_title">Enable experimental feature?</string> + <!-- The title used in a confirmation dialog when toggling off an experimental feature. --> + <string name="firefox_labs_disable_feature_dialog_title">Disable experimental feature?</string> + <!-- The description that is displayed in the confirmation dialog for toggling on and off experimental features. --> + <string name="firefox_labs_enable_feature_dialog_message">Toggling this feature will restart %s.</string> + <!-- The text for the positive button in the dialog for toggling on and off experimental features. This prompts the user to confirm that they want to restart the app. --> + <string name="firefox_labs_dialog_restart_button">Restart</string> + <!-- The text for the negative button in the dialog for toggling on and off experimental features. --> + <string name="firefox_labs_dialog_cancel_button">Cancel</string> + <!-- The title used in a confirmation dialog when restoring the application to its defaults by turning off any experimental features. --> + <string name="firefox_labs_restore_defaults_dialog_title">Restore defaults?</string> + <!-- The description used in a confirmation dialog when restoring the application to its defaults by turning off any experimental features. --> + <string name="firefox_labs_restore_defaults_dialog_message">Resetting experimental features will restart %s.</string> </resources> diff --git a/mobile/android/fenix/app/src/main/res/xml/preferences.xml b/mobile/android/fenix/app/src/main/res/xml/preferences.xml @@ -183,6 +183,12 @@ app:iconSpaceReserved="false" android:title="@string/preferences_downloads" /> + <androidx.preference.Preference + android:key="@string/pref_key_firefox_labs" + app:iconSpaceReserved="false" + app:isPreferenceVisible="false" + android:title="@string/firefox_labs_title" /> + <androidx.preference.SwitchPreference android:key="@string/pref_key_leakcanary" android:title="@string/preference_leakcanary" diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/labs/middleware/LabsMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/labs/middleware/LabsMiddlewareTest.kt @@ -0,0 +1,127 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.labs.middleware + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import mozilla.components.support.test.middleware.CaptureActionsMiddleware +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.verify +import org.mozilla.fenix.R +import org.mozilla.fenix.settings.labs.FeatureKey +import org.mozilla.fenix.settings.labs.LabsFeature +import org.mozilla.fenix.settings.labs.store.DialogState +import org.mozilla.fenix.settings.labs.store.LabsAction +import org.mozilla.fenix.settings.labs.store.LabsState +import org.mozilla.fenix.settings.labs.store.LabsStore +import org.mozilla.fenix.utils.Settings +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class LabsMiddlewareTest { + + private lateinit var settings: Settings + private val onRestart: () -> Unit = mock() + + @Before + fun setup() { + settings = Settings(testContext) + settings.enableHomepageAsNewTab = false + } + + @Test + fun `WHEN InitAction is dispatched THEN features are initialized from settings`() = runTest(UnconfinedTestDispatcher()) { + val captureMiddleware = CaptureActionsMiddleware<LabsState, LabsAction>() + createStore( + captureMiddleware = captureMiddleware, + scope = backgroundScope, + ) + + // InitAction is dispatched on store creation. + // The middleware then dispatches UpdateFeatures. + captureMiddleware.assertLastAction(LabsAction.UpdateFeatures::class) { action -> + assertEquals(1, action.features.size) + val feature = action.features.first() + assertEquals(FeatureKey.HOMEPAGE_AS_A_NEW_TAB, feature.key) + assertEquals(settings.enableHomepageAsNewTab, feature.enabled) + } + } + + @Test + fun `WHEN RestartApplication action is dispatched THEN onRestart is called`() = runTest(UnconfinedTestDispatcher()) { + val store = createStore(scope = backgroundScope) + + store.dispatch(LabsAction.RestartApplication) + + verify(onRestart).invoke() + } + + @Test + fun `WHEN RestoreDefaults action is dispatched THEN all features are disabled and app restart is requested`() = runTest(UnconfinedTestDispatcher()) { + settings.enableHomepageAsNewTab = true + val captureMiddleware = CaptureActionsMiddleware<LabsState, LabsAction>() + val store = createStore( + captureMiddleware = captureMiddleware, + scope = backgroundScope, + ) + + store.dispatch(LabsAction.RestoreDefaults) + + assertFalse(settings.enableHomepageAsNewTab) + captureMiddleware.assertLastAction(LabsAction.RestartApplication::class) + } + + @Test + fun `WHEN ToggleFeature action is dispatched THEN feature is toggled and app restart is requested`() = runTest(UnconfinedTestDispatcher()) { + val feature = LabsFeature( + key = FeatureKey.HOMEPAGE_AS_A_NEW_TAB, + name = R.string.firefox_labs_homepage_as_a_new_tab, + description = R.string.firefox_labs_homepage_as_a_new_tab_description, + enabled = false, + ) + val captureMiddleware = CaptureActionsMiddleware<LabsState, LabsAction>() + val store = createStore( + initialState = LabsState( + labsFeatures = listOf(feature), + dialogState = DialogState.Closed, + ), + captureMiddleware = captureMiddleware, + scope = backgroundScope, + ) + + assertFalse(settings.enableHomepageAsNewTab) + + store.dispatch(LabsAction.ToggleFeature(feature)) + + assertTrue(settings.enableHomepageAsNewTab) + captureMiddleware.assertLastAction(LabsAction.RestartApplication::class) + } + + private fun createStore( + initialState: LabsState = LabsState.INITIAL, + captureMiddleware: CaptureActionsMiddleware<LabsState, LabsAction> = CaptureActionsMiddleware(), + scope: CoroutineScope, + ): LabsStore { + val middleware = LabsMiddleware( + settings = settings, + onRestart = onRestart, + scope = scope, + ) + return LabsStore( + initialState = initialState, + middleware = listOf(captureMiddleware, middleware), + ) + } +} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/labs/store/LabsStoreTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/settings/labs/store/LabsStoreTest.kt @@ -0,0 +1,160 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.settings.labs.store + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.test.runTest +import mozilla.components.lib.state.Middleware +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.fenix.R +import org.mozilla.fenix.settings.labs.FeatureKey +import org.mozilla.fenix.settings.labs.LabsFeature + +@RunWith(AndroidJUnit4::class) +class LabsStoreTest { + + @Test + fun `WHEN store is created THEN init action is dispatched`() { + var initActionObserved = false + val testMiddleware: Middleware<LabsState, LabsAction> = { _, next, action -> + if (action == LabsAction.InitAction) { + initActionObserved = true + } + + next(action) + } + + LabsStore( + initialState = LabsState.INITIAL, + middleware = listOf(testMiddleware), + ) + + assertTrue(initActionObserved) + } + + @Test + fun `WHEN UpdateFeatures action is dispatched THEN labsFeatures are updated`() = runTest { + val store = LabsStore(initialState = LabsState.INITIAL) + + assertTrue(store.state.labsFeatures.isEmpty()) + + val features = listOf( + LabsFeature( + key = FeatureKey.HOMEPAGE_AS_A_NEW_TAB, + name = R.string.firefox_labs_homepage_as_a_new_tab, + description = R.string.firefox_labs_homepage_as_a_new_tab_description, + enabled = false, + ), + ) + store.dispatch(LabsAction.UpdateFeatures(features)) + + assertEquals(features, store.state.labsFeatures) + } + + @Test + fun `WHEN RestoreDefaults action is dispatched THEN all features are disabled`() = runTest { + val features = listOf( + LabsFeature( + key = FeatureKey.HOMEPAGE_AS_A_NEW_TAB, + name = R.string.firefox_labs_homepage_as_a_new_tab, + description = R.string.firefox_labs_homepage_as_a_new_tab_description, + enabled = true, + ), + ) + val store = LabsStore( + initialState = LabsState( + labsFeatures = features, + dialogState = DialogState.RestoreDefaults, + ), + ) + + store.dispatch(LabsAction.RestoreDefaults) + + store.state.labsFeatures.forEach { + assertFalse(it.enabled) + } + assertEquals(DialogState.Closed, store.state.dialogState) + } + + @Test + fun `WHEN ToggleFeature action is dispatched THEN feature is toggled`() = runTest { + val feature = LabsFeature( + key = FeatureKey.HOMEPAGE_AS_A_NEW_TAB, + name = R.string.firefox_labs_homepage_as_a_new_tab, + description = R.string.firefox_labs_homepage_as_a_new_tab_description, + enabled = false, + ) + val store = LabsStore( + initialState = LabsState( + labsFeatures = listOf(feature), + dialogState = DialogState.ToggleFeature(feature), + ), + ) + + assertFalse(store.state.labsFeatures.first().enabled) + + store.dispatch(LabsAction.ToggleFeature(feature)) + + assertTrue(store.state.labsFeatures.first().enabled) + assertEquals(DialogState.Closed, store.state.dialogState) + + store.dispatch(LabsAction.ToggleFeature(feature)) + + assertFalse(store.state.labsFeatures.first().enabled) + assertEquals(DialogState.Closed, store.state.dialogState) + } + + @Test + fun `WHEN ShowToggleFeatureDialog action is dispatched THEN dialogState is updated`() = runTest { + val store = LabsStore(initialState = LabsState.INITIAL) + val feature = LabsFeature( + key = FeatureKey.HOMEPAGE_AS_A_NEW_TAB, + name = R.string.firefox_labs_homepage_as_a_new_tab, + description = R.string.firefox_labs_homepage_as_a_new_tab_description, + enabled = false, + ) + + assertEquals(DialogState.Closed, store.state.dialogState) + + store.dispatch(LabsAction.ShowToggleFeatureDialog(feature)) + + assertEquals(DialogState.ToggleFeature(feature), store.state.dialogState) + } + + @Test + fun `WHEN ShowRestoreDefaultsDialog action is dispatched THEN dialogState is updated`() = runTest { + val store = LabsStore(initialState = LabsState.INITIAL) + assertEquals(DialogState.Closed, store.state.dialogState) + + store.dispatch(LabsAction.ShowRestoreDefaultsDialog) + + assertEquals(DialogState.RestoreDefaults, store.state.dialogState) + } + + @Test + fun `WHEN CloseDialog action is dispatched THEN dialogState is updated to Closed`() = runTest { + val feature = LabsFeature( + key = FeatureKey.HOMEPAGE_AS_A_NEW_TAB, + name = R.string.firefox_labs_homepage_as_a_new_tab, + description = R.string.firefox_labs_homepage_as_a_new_tab_description, + enabled = false, + ) + val store = LabsStore( + initialState = LabsState( + labsFeatures = listOf(feature), + dialogState = DialogState.RestoreDefaults, + ), + ) + assertEquals(DialogState.RestoreDefaults, store.state.dialogState) + + store.dispatch(LabsAction.CloseDialog) + + assertEquals(DialogState.Closed, store.state.dialogState) + } +}