Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/* 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

import mozilla.components.concept.ai.controls.AIFeatureMetadata
import mozilla.components.lib.state.Action

/**
* Actions that can be dispatched on the settings [SettingsStore].
*/
sealed class SettingsAction : Action {
/** Dispatched once when the settings screen is first created. */
data object SettingsViewCreated : SettingsAction()

/** Emitted when an AI feature's enabled state has been loaded. */
data class AIControlsFeatureStateLoaded(
val enabled: Boolean,
val id: AIFeatureMetadata.FeatureId,
) : SettingsAction()

/** Emitted when the page-summaries settings have been loaded from disk. */
data class PageSummariesSettingsLoaded(
val isFeatureEnabled: Boolean,
val isGestureEnabled: Boolean,
) : SettingsAction()
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import mozilla.components.concept.sync.AuthType
import mozilla.components.concept.sync.OAuthAccount
import mozilla.components.concept.sync.Profile
import mozilla.components.feature.addons.ui.AddonFilePicker
import mozilla.components.feature.summarize.settings.SummarizationSettings
import mozilla.components.lib.state.helpers.StoreProvider.Companion.navBackStackStore
import mozilla.components.service.fxrelay.eligibility.Eligible
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
import mozilla.components.support.ktx.android.view.showKeyboard
Expand Down Expand Up @@ -83,6 +85,7 @@ import org.mozilla.fenix.snackbar.FenixSnackbarDelegate
import org.mozilla.fenix.snackbar.SnackbarBinding
import org.mozilla.fenix.utils.Settings
import java.lang.ref.WeakReference
import kotlin.getValue
import kotlin.system.exitProcess
import mozilla.components.ui.icons.R as iconsR
import org.mozilla.fenix.GleanMetrics.Settings as SettingsMetrics
Expand Down Expand Up @@ -132,6 +135,8 @@ class SettingsFragment : PreferenceFragmentCompat(), SystemInsetsPaddedFragment

components = requireContext().components

initializeSettingsStore()

accountUiView = AccountUiView(
fragment = this,
scope = lifecycleScope,
Expand Down Expand Up @@ -205,6 +210,27 @@ class SettingsFragment : PreferenceFragmentCompat(), SystemInsetsPaddedFragment
setPreferencesFromResource(R.xml.preferences, rootKey)
}

private fun initializeSettingsStore() {
val settingsOwner = findNavController().getBackStackEntry(R.id.settingsFragment)
val settingsStore: SettingsStore by findNavController().currentBackStackEntry!!.navBackStackStore(
initialState = SettingsState(),
factory = {
SettingsStore(
initialState = SettingsState(),
reducer = ::settingsReducer,
middleware = listOf(
SettingsMiddleware(
featureRegistry = components.aiFeatureRegistry,
summarizationSettings = SummarizationSettings.dataStore(requireContext()),
scope = settingsOwner.lifecycleScope,
),
),
)
},
)
settingsStore.dispatch(SettingsAction.SettingsViewCreated)
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/* 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

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import mozilla.components.concept.ai.controls.AIFeatureRegistry
import mozilla.components.feature.summarize.settings.SummarizationSettings
import mozilla.components.lib.state.Middleware
import mozilla.components.lib.state.Store

/**
* Middleware that observes external sources (AI feature registry, summarization settings)
* and dispatches load actions into the settings [SettingsStore].
*/
class SettingsMiddleware(
val featureRegistry: AIFeatureRegistry,
val summarizationSettings: SummarizationSettings,
val scope: CoroutineScope,
) : Middleware<SettingsState, SettingsAction> {
override fun invoke(
store: Store<SettingsState, SettingsAction>,
next: (SettingsAction) -> Unit,
action: SettingsAction,
) {
next(action)
when (action) {
is SettingsAction.SettingsViewCreated -> {
featureRegistry.getFeatures().forEach { feature ->
scope.launch {
feature.isEnabled.collect {
store.dispatch(SettingsAction.AIControlsFeatureStateLoaded(it, feature.id))
}
}
}
scope.launch {
combine(
summarizationSettings.getFeatureEnabledUserStatus(),
summarizationSettings.getGestureEnabledUserStatus(),
) { isFeatureEnabled, isGestureEnabled ->
SettingsAction.PageSummariesSettingsLoaded(
isFeatureEnabled = isFeatureEnabled,
isGestureEnabled = isGestureEnabled,
)
}.collect { store.dispatch(it) }
}
}

is SettingsAction.AIControlsFeatureStateLoaded -> Unit
is SettingsAction.PageSummariesSettingsLoaded -> Unit
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/* 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

/**
* Reducer for [SettingsStore] — applies [SettingsAction]s to [SettingsState].
*/
fun settingsReducer(state: SettingsState, action: SettingsAction): SettingsState = when (action) {
is SettingsAction.AIControlsFeatureStateLoaded -> state.copy(
aiControlsState = state.aiControlsState.copy(
featuresEnabled = state.aiControlsState.featuresEnabled + (action.id to action.enabled),
),
)
is SettingsAction.PageSummariesSettingsLoaded -> state.copy(
summarizeSettingsState = state.summarizeSettingsState.copy(
isFeatureEnabled = action.isFeatureEnabled,
isGestureEnabled = action.isGestureEnabled,
),
)
SettingsAction.SettingsViewCreated -> state
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/* 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

import mozilla.components.concept.ai.controls.AIFeatureMetadata
import mozilla.components.feature.summarize.settings.SummarizeSettingsState
import mozilla.components.lib.state.State

/**
* State for the AI Controls section of the settings screen.
*/
data class AIControlsState(val featuresEnabled: Map<AIFeatureMetadata.FeatureId, Boolean> = mapOf())

/**
* State for the settings screen.
*/
data class SettingsState(
val aiControlsState: AIControlsState = AIControlsState(),
val summarizeSettingsState: SummarizeSettingsState = SummarizeSettingsState(),
) : State
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/* 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

import mozilla.components.lib.state.Store

/**
* [Store] for handling [SettingsState] and dispatching [SettingsAction]s.
*/
typealias SettingsStore = Store<SettingsState, SettingsAction>
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,29 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.fragment.app.Fragment
import androidx.fragment.compose.content
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import mozilla.components.lib.state.helpers.StoreProvider.Companion.storeProvider
import mozilla.telemetry.glean.private.NoExtras
import org.mozilla.fenix.GleanMetrics.GenaiAiControls
import org.mozilla.fenix.R
import org.mozilla.fenix.e2e.SystemInsetsPaddedFragment
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.showToolbar
import org.mozilla.fenix.settings.SettingsState
import org.mozilla.fenix.settings.SettingsStore
import org.mozilla.fenix.settings.SupportUtils
import org.mozilla.fenix.settings.settingsReducer
import org.mozilla.fenix.theme.FirefoxTheme
import kotlin.getValue

/**
* A fragment displaying the AI Controls settings screen.
Expand All @@ -34,74 +42,98 @@ class AIControlsFragment : Fragment(), SystemInsetsPaddedFragment {
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View = content {
val registry = requireComponents.aiFeatureRegistry
val features = remember { registry.getFeatures() }
val featureBlock = requireComponents.aiControlsFeatureBlock
val scope = rememberCoroutineScope()
): View {
val navController = findNavController()
val settingsStore: SettingsStore =
navController.getBackStackEntry(R.id.settingsFragment)
.storeProvider.get<SettingsState, SettingsStore> { persistedState ->
SettingsStore(persistedState ?: SettingsState(), ::settingsReducer, middleware = listOf())
}

val aiBlockUiController = remember {
AIBlockUIController.default(
featureBlock = featureBlock,
scope = scope,
)
}
return content {
val registry = requireComponents.aiFeatureRegistry
val features = remember { registry.getFeatures() }
val featureBlock = requireComponents.aiControlsFeatureBlock
val scope = rememberCoroutineScope()

val aiBlockUiController = remember {
AIBlockUIController.default(
featureBlock = featureBlock,
scope = scope,
)
}

val showDialog = aiBlockUiController.showDialogFlow.collectAsState()
val isBlocked = featureBlock.isBlocked.collectAsState(initial = false)
val showDialog = aiBlockUiController.showDialogFlow.collectAsState()
val isBlocked = featureBlock.isBlocked.collectAsState(initial = false)
val settingsState = settingsStore.stateFlow.collectAsState()
val callbacks = rememberAIControlsCallbacks(aiBlockUiController, scope)

FirefoxTheme {
AIControlsScreen(
registeredFeatures = features,
showDialog = showDialog.value,
isBlocked = isBlocked.value,
itemToScrollTo = args.preferenceToScrollTo,
onDialogDismiss = {
GenaiAiControls.globalPrefConfirmationClick.record(
GenaiAiControls.GlobalPrefConfirmationClickExtra(element = "cancel"),
)
aiBlockUiController.onDialogDismiss()
},
onDialogConfirm = {
GenaiAiControls.globalPrefConfirmationClick.record(
GenaiAiControls.GlobalPrefConfirmationClickExtra(element = "block"),
)
aiBlockUiController.onDialogConfirm()
},
onToggle = { currentlyBlocked ->
GenaiAiControls.globalPrefToggle.record(
GenaiAiControls.GlobalPrefToggleExtra(block = !currentlyBlocked),
)
if (!currentlyBlocked) {
GenaiAiControls.globalPrefConfirmationShown.record(NoExtras())
}
aiBlockUiController.onToggle(currentlyBlocked)
},
onFeatureToggle = { feature, enabled ->
GenaiAiControls.featurePrefChange.record(
GenaiAiControls.FeaturePrefChangeExtra(
feature = feature.id.value,
selection = if (enabled) "enabled" else "blocked",
),
)
scope.launch { feature.set(enabled) }
},
onFeatureNavLinkClick = { destination, featureId ->
GenaiAiControls.featureLinkClick.record(
GenaiAiControls.FeatureLinkClickExtra(link = featureId),
)
destination.nav(this)
},
onBannerLearnMoreClick = {
GenaiAiControls.featureLinkClick.record(
GenaiAiControls.FeatureLinkClickExtra(link = "global_control"),
)
openAiControlsSumoPage()
},
)
FirefoxTheme {
AIControlsScreen(
state = AIControlsScreenState(
featureEnabledState = settingsState.value.aiControlsState.featuresEnabled,
registeredFeatures = features,
showDialog = showDialog.value,
isBlocked = isBlocked.value,
itemToScrollTo = args.preferenceToScrollTo,
),
callbacks = callbacks,
)
}
}
}

@Composable
private fun rememberAIControlsCallbacks(
aiBlockUiController: AIBlockUIController,
scope: CoroutineScope,
): AIControlsCallbacks = remember {
AIControlsCallbacks(
onDialogDismiss = {
GenaiAiControls.globalPrefConfirmationClick.record(
GenaiAiControls.GlobalPrefConfirmationClickExtra(element = "cancel"),
)
aiBlockUiController.onDialogDismiss()
},
onDialogConfirm = {
GenaiAiControls.globalPrefConfirmationClick.record(
GenaiAiControls.GlobalPrefConfirmationClickExtra(element = "block"),
)
aiBlockUiController.onDialogConfirm()
},
onToggle = { currentlyBlocked ->
GenaiAiControls.globalPrefToggle.record(
GenaiAiControls.GlobalPrefToggleExtra(block = !currentlyBlocked),
)
if (!currentlyBlocked) {
GenaiAiControls.globalPrefConfirmationShown.record(NoExtras())
}
aiBlockUiController.onToggle(currentlyBlocked)
},
onFeatureToggle = { feature, enabled ->
GenaiAiControls.featurePrefChange.record(
GenaiAiControls.FeaturePrefChangeExtra(
feature = feature.id.value,
selection = if (enabled) "enabled" else "blocked",
),
)
scope.launch { feature.set(enabled) }
},
onFeatureNavLinkClick = { destination, featureId ->
GenaiAiControls.featureLinkClick.record(
GenaiAiControls.FeatureLinkClickExtra(link = featureId),
)
destination.nav(this@AIControlsFragment)
},
onBannerLearnMoreClick = {
GenaiAiControls.featureLinkClick.record(
GenaiAiControls.FeatureLinkClickExtra(link = "global_control"),
)
openAiControlsSumoPage()
},
)
}

override fun onResume() {
super.onResume()
// Ensures the toolbar shows when navigating to this fragment via Global Directions.
Expand Down
Loading