commit 2a496edebb2ec930dae3ea146a7e9f64e233061c parent 0d35bd1655f9868b796df7835c990efd9d2909a2 Author: mike a. <mavduevskiy@mozilla.com> Date: Tue, 18 Nov 2025 06:20:15 +0000 Bug 1955887 - Part 1: Introduce a data layer to the app icon selector feature r=android-reviewers,marcin,twhite Differential Revision: https://phabricator.services.mozilla.com/D270765 Diffstat:
9 files changed, 290 insertions(+), 43 deletions(-)
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/iconpicker/AppIconAction.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/iconpicker/AppIconAction.kt @@ -0,0 +1,32 @@ +/* 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.iconpicker + +import mozilla.components.lib.state.Action + +/** + * Actions related to the app icon selection screen. + */ +sealed interface AppIconAction : Action { + /** + * User has selected a new [appIcon] to set as the app icon. This does not mean it is applied. + * + * @property appIcon the new selected icon. + */ + data class SelectAppIcon(val appIcon: AppIcon) : AppIconAction + + /** + * Reset the selection from the one selected by the user back to the icon currently used by the app. + */ + data object ResetSelection : AppIconAction + + /** + * Set the new icon as the app icon on a system level. + * + * @property newIcon the icon to apply. + * @property currentIcon the current app icon used by the system. + */ + data class ApplyAppIcon(val newIcon: AppIcon, val currentIcon: AppIcon) : AppIconAction +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/iconpicker/AppIconMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/iconpicker/AppIconMiddleware.kt @@ -0,0 +1,42 @@ +/* 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.iconpicker + +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.MiddlewareContext + +/** + * A middleware for handling side-effects in response to [AppIconAction]s. + * + * @property updateAppIcon A interface that updates the main activity alias with the newly selected one. + */ +class AppIconMiddleware( + val updateAppIcon: AppIconUpdater, +) : Middleware<AppIconState, AppIconAction> { + + override fun invoke( + context: MiddlewareContext<AppIconState, AppIconAction>, + next: (AppIconAction) -> Unit, + action: AppIconAction, + ) { + next(action) + + when (action) { + is AppIconAction.ApplyAppIcon -> updateAppIcon( + new = action.newIcon, + old = action.currentIcon, + ) + + is AppIconAction.SelectAppIcon, AppIconAction.ResetSelection -> Unit + } + } +} + +/** + * An interface for applying a new app icon. + */ +fun interface AppIconUpdater : (AppIcon, AppIcon) -> Unit { + override fun invoke(new: AppIcon, old: AppIcon) +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/iconpicker/AppIconReducer.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/iconpicker/AppIconReducer.kt @@ -0,0 +1,14 @@ +/* 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.iconpicker + +/** + * Function for reducing a new app icon state based on the received action. + */ +internal fun appIconReducer(state: AppIconState, action: AppIconAction) = when (action) { + is AppIconAction.SelectAppIcon -> state.copy(userSelectedAppIcon = action.appIcon) + AppIconAction.ResetSelection -> state.copy(userSelectedAppIcon = null) + is AppIconAction.ApplyAppIcon -> state.copy(currentAppIcon = action.newIcon, userSelectedAppIcon = null) +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/iconpicker/AppIconState.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/iconpicker/AppIconState.kt @@ -0,0 +1,20 @@ +/* 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.iconpicker + +import mozilla.components.lib.state.State + +/** + * Represents the state of the app icon picker screen. + * + * @property currentAppIcon The icon currently being used by the application. + * @property userSelectedAppIcon The icon the user has selected in the picker, if any. + * @property groupedIconOptions A map of all available app icons. + */ +data class AppIconState( + val currentAppIcon: AppIcon = AppIcon.AppDefault, + val userSelectedAppIcon: AppIcon? = null, + val groupedIconOptions: Map<IconGroupTitle, List<AppIcon>> = mapOf(), +) : State diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/iconpicker/AppIconStore.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/iconpicker/AppIconStore.kt @@ -0,0 +1,26 @@ +/* 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.iconpicker + +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.Reducer +import mozilla.components.lib.state.Store + +/** + * A Store for handling [AppIconState] and dispatching [AppIconAction]. + * + * @param initialState The initial state of the Store. + * @param reducer Reducer to handle state updates based on dispatched actions. + * @param middleware A list of Middleware to handle side-effects in response to dispatched actions. + */ +class AppIconStore( + initialState: AppIconState, + reducer: Reducer<AppIconState, AppIconAction> = ::appIconReducer, + middleware: List<Middleware<AppIconState, AppIconAction>> = listOf(), +) : Store<AppIconState, AppIconAction>( + initialState = initialState, + reducer = reducer, + middleware = middleware, +) diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/iconpicker/ui/AppIconSelection.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/iconpicker/ui/AppIconSelection.kt @@ -27,9 +27,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -45,9 +42,13 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import mozilla.components.compose.base.annotation.FlexibleWindowLightDarkPreview import mozilla.components.compose.base.button.TextButton +import mozilla.components.lib.state.ext.observeAsState import org.mozilla.fenix.R import org.mozilla.fenix.compose.button.RadioButton import org.mozilla.fenix.iconpicker.AppIcon +import org.mozilla.fenix.iconpicker.AppIconAction +import org.mozilla.fenix.iconpicker.AppIconState +import org.mozilla.fenix.iconpicker.AppIconStore import org.mozilla.fenix.iconpicker.DefaultAppIconRepository import org.mozilla.fenix.iconpicker.DefaultPackageManagerWrapper import org.mozilla.fenix.iconpicker.IconBackground @@ -66,24 +67,19 @@ private val GroupSpacerHeight = 8.dp /** * A composable that displays a list of app icon options. * - * @param currentAppIcon The currently selected app icon alias. - * @param groupedIconOptions Icons are displayed in sections under their respective titles. - * @param onAppIconSelected A callback invoked when the user has confirmed an alternative icon to be - * applied (they get informed about the required restart providing an opportunity to back out). + * @param store A store for managing the app icon selection screen state. */ @Composable fun AppIconSelection( - currentAppIcon: AppIcon, - groupedIconOptions: Map<IconGroupTitle, List<AppIcon>>, - onAppIconSelected: (AppIcon) -> Unit, + store: AppIconStore, ) { - var currentAppIcon by remember { mutableStateOf(currentAppIcon) } - var selectedAppIcon by remember { mutableStateOf<AppIcon?>(null) } + val state by store.observeAsState(store.state) { it } + val selectedIcon = state.userSelectedAppIcon ?: state.currentAppIcon LazyColumn( modifier = Modifier.background(color = FirefoxTheme.colors.layer1), ) { - groupedIconOptions.forEach { (header, icons) -> + state.groupedIconOptions.forEach { (header, icons) -> item(contentType = { header::class }) { AppIconGroupHeader(header) } @@ -92,14 +88,14 @@ fun AppIconSelection( items = icons, contentType = { item -> item::class }, ) { icon -> - val iconSelected = icon == currentAppIcon + val iconSelected = icon == selectedIcon AppIconOption( appIcon = icon, selected = iconSelected, onClick = { if (!iconSelected) { - selectedAppIcon = icon + store.dispatch(AppIconAction.SelectAppIcon(icon)) } }, ) @@ -113,15 +109,13 @@ fun AppIconSelection( } } - selectedAppIcon?.let { + state.userSelectedAppIcon?.let { RestartWarningDialog( onConfirm = { - currentAppIcon = it - onAppIconSelected(it) - selectedAppIcon = null + store.dispatch(AppIconAction.ApplyAppIcon(it, state.currentAppIcon)) }, onDismiss = { - selectedAppIcon = null + store.dispatch(AppIconAction.ResetSelection) }, ) } @@ -290,12 +284,16 @@ private fun RestartWarningDialog( private fun AppIconSelectionPreview() { FirefoxTheme { AppIconSelection( - currentAppIcon = AppIcon.AppDefault, - groupedIconOptions = DefaultAppIconRepository( - packageManager = DefaultPackageManagerWrapper(LocalContext.current.packageManager), - packageName = LocalContext.current.packageName, - ).groupedAppIcons, - onAppIconSelected = {}, + store = AppIconStore( + initialState = AppIconState( + currentAppIcon = AppIcon.AppDefault, + userSelectedAppIcon = null, + groupedIconOptions = DefaultAppIconRepository( + packageManager = DefaultPackageManagerWrapper(LocalContext.current.packageManager), + packageName = LocalContext.current.packageName, + ).groupedAppIcons, + ), + ), ) } } diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/iconpicker/ui/AppIconSelectionFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/iconpicker/ui/AppIconSelectionFragment.kt @@ -12,10 +12,12 @@ import androidx.fragment.app.Fragment import androidx.fragment.compose.content import androidx.navigation.fragment.findNavController import mozilla.components.support.base.feature.UserInteractionHandler -import org.mozilla.fenix.GleanMetrics.AppIconSelection import org.mozilla.fenix.R import org.mozilla.fenix.ext.showToolbar +import org.mozilla.fenix.iconpicker.AppIconMiddleware import org.mozilla.fenix.iconpicker.AppIconRepository +import org.mozilla.fenix.iconpicker.AppIconState +import org.mozilla.fenix.iconpicker.AppIconStore import org.mozilla.fenix.iconpicker.DefaultAppIconRepository import org.mozilla.fenix.iconpicker.DefaultPackageManagerWrapper import org.mozilla.fenix.theme.FirefoxTheme @@ -42,23 +44,22 @@ class AppIconSelectionFragment : Fragment(), UserInteractionHandler { ) = content { FirefoxTheme { AppIconSelection( - currentAppIcon = appIconRepository.selectedAppIcon, - groupedIconOptions = appIconRepository.groupedAppIcons, - onAppIconSelected = { selectedAppIcon -> - val currentAliasSuffix = appIconRepository.selectedAppIcon.aliasSuffix - - AppIconSelection.appIconSelectionConfirmed.record( - extra = AppIconSelection.AppIconSelectionConfirmedExtra( - oldIcon = currentAliasSuffix, - newIcon = selectedAppIcon.aliasSuffix, + store = AppIconStore( + initialState = AppIconState( + currentAppIcon = appIconRepository.selectedAppIcon, + groupedIconOptions = appIconRepository.groupedAppIcons, + ), + middleware = listOf( + AppIconMiddleware( + updateAppIcon = { newIcon, currentIcon -> + updateAppIcon( + currentAliasSuffix = currentIcon.aliasSuffix, + newAliasSuffix = newIcon.aliasSuffix, + ) + }, ), - ) - - updateAppIcon( - currentAliasSuffix = currentAliasSuffix, - newAliasSuffix = selectedAppIcon.aliasSuffix, - ) - }, + ), + ), ) } } diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/iconpicker/AppIconMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/iconpicker/AppIconMiddlewareTest.kt @@ -0,0 +1,40 @@ +/* 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.iconpicker + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.support.test.ext.joinBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AppIconMiddlewareTest { + + @Test + fun `WHEN the store receives an ApplyAppIcon action THEN the middleware calls the updateAppIcon interface with the correct data from the action`() { + val currentIcon = AppIcon.AppDefault + val newIcon = AppIcon.AppRetro2004 + var updatedCurrentIcon: AppIcon? = null + var updatedNewIcon: AppIcon? = null + val middleware = AppIconMiddleware { newIcon, currentIcon -> + updatedNewIcon = newIcon + updatedCurrentIcon = currentIcon + } + val store = AppIconStore( + initialState = AppIconState( + currentAppIcon = currentIcon, + userSelectedAppIcon = null, + groupedIconOptions = mapOf(), + ), + middleware = listOf(middleware), + ) + + store.dispatch(AppIconAction.ApplyAppIcon(newIcon = newIcon, currentIcon = currentIcon)).joinBlocking() + + assertEquals(currentIcon, updatedCurrentIcon) + assertEquals(newIcon, updatedNewIcon) + } +} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/iconpicker/AppIconReducerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/iconpicker/AppIconReducerTest.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.iconpicker + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AppIconReducerTest { + @Test + fun `GIVEN SelectAppIcon action WHEN reducer is called THEN state is updated with the selected icon`() { + val initialState = AppIconState() + val newIcon = AppIcon.AppRetro2004 + + assertEquals(null, initialState.userSelectedAppIcon) + + val result = appIconReducer(initialState, AppIconAction.SelectAppIcon(newIcon)) + + assertEquals(newIcon, result.userSelectedAppIcon) + } + + @Test + fun `GIVEN SelectAppIcon action WHEN reducer is called THEN the current icon is not changed`() { + val initialState = AppIconState() + val newIcon = AppIcon.AppRetro2004 + + assertEquals(AppIcon.AppDefault, initialState.currentAppIcon) + + val result = appIconReducer(initialState, AppIconAction.SelectAppIcon(newIcon)) + + assertEquals(AppIcon.AppDefault, result.currentAppIcon) + } + + @Test + fun `GIVEN ResetSelection action WHEN reducer is called THEN state resets the user selected icon to null`() { + val initialState = AppIconState(userSelectedAppIcon = AppIcon.AppRetro2004) + + assertEquals(AppIcon.AppRetro2004, initialState.userSelectedAppIcon) + + val result = appIconReducer(initialState, AppIconAction.ResetSelection) + + assertEquals(null, result.userSelectedAppIcon) + } + + @Test + fun `GIVEN ResetSelection action WHEN reducer is called THEN the current icon is not changed`() { + val initialState = AppIconState() + + assertEquals(AppIcon.AppDefault, initialState.currentAppIcon) + + val result = appIconReducer(initialState, AppIconAction.ResetSelection) + + assertEquals(AppIcon.AppDefault, result.currentAppIcon) + } + + @Test + fun `GIVEN ApplyAppIcon action WHEN reducer is called THEN the new icon becomes the new current and user selected icon resets`() { + val newIcon = AppIcon.AppRetro2004 + val currentIcon = AppIcon.AppDefault + val initialState = AppIconState( + currentAppIcon = currentIcon, + userSelectedAppIcon = newIcon, + ) + + assertEquals(currentIcon, initialState.currentAppIcon) + assertEquals(newIcon, initialState.userSelectedAppIcon) + + val result = appIconReducer(initialState, AppIconAction.ApplyAppIcon(newIcon = newIcon, currentIcon = currentIcon)) + + assertEquals(newIcon, result.currentAppIcon) + assertEquals(null, result.userSelectedAppIcon) + } +}