commit 74b132cd9148fd71ef4ae9b1f8f82b1474d9dfe6 parent f37efeb9fd346125bfc98d132ae0dea48a1e2584 Author: John Oberhauser <j.git-global@obez.io> Date: Tue, 16 Dec 2025 16:04:17 +0000 Bug 2004410: Part 1 - Adding the Privacy Notice banner store components and tests r=android-reviewers,twhite Differential Revision: https://phabricator.services.mozilla.com/D275837 Diffstat:
9 files changed, 389 insertions(+), 0 deletions(-)
diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/termsofuse/TermsOfUseVersion.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/termsofuse/TermsOfUseVersion.kt @@ -8,3 +8,8 @@ package org.mozilla.fenix.termsofuse * The current version of the Terms of Use. */ const val TOU_VERSION = 5 + +/** + * The timestamp of the latest Terms of Use published date. + */ +const val TOU_TIME_IN_MILLIS = 1765800000000 // December 15th, 2025 diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/termsofuse/store/PrivacyNoticeBannerMiddleware.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/termsofuse/store/PrivacyNoticeBannerMiddleware.kt @@ -0,0 +1,39 @@ +/* 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.termsofuse.store + +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.MiddlewareContext + +/** + * [Middleware] that reacts to various [PrivacyNoticeBannerAction]s + * + * @param repository The repository for the Privacy Notice banner. + */ +class PrivacyNoticeBannerMiddleware( + private val repository: PrivacyNoticeBannerRepository, +) : Middleware<PrivacyNoticeBannerState, PrivacyNoticeBannerAction> { + + override fun invoke( + context: MiddlewareContext<PrivacyNoticeBannerState, PrivacyNoticeBannerAction>, + next: (PrivacyNoticeBannerAction) -> Unit, + action: PrivacyNoticeBannerAction, + ) { + when (action) { + is PrivacyNoticeBannerAction.OnBannerDisplayed -> + repository.updatePrivacyNoticeBannerDisplayedPreference() + + // no-ops + is PrivacyNoticeBannerAction.OnPrivacyNoticeClicked, + is PrivacyNoticeBannerAction.OnLearnMoreClicked, + is PrivacyNoticeBannerAction.OnCloseClicked, + is PrivacyNoticeBannerAction.OnFragmentStopped, + -> { + } + } + + next(action) + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/termsofuse/store/PrivacyNoticeBannerRepository.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/termsofuse/store/PrivacyNoticeBannerRepository.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.termsofuse.store + +import org.mozilla.fenix.termsofuse.TOU_TIME_IN_MILLIS +import org.mozilla.fenix.utils.Settings + +/** + * Repository related to the Privacy Notice banner. + */ +interface PrivacyNoticeBannerRepository { + /** + * Updates the preference that tracks the last time the user saw the Privacy Notice banner. + */ + fun updatePrivacyNoticeBannerDisplayedPreference(nowMillis: Long = System.currentTimeMillis()) + + /** + * Determines if the Privacy Notice banner should be shown. + */ + fun shouldShowPrivacyNoticeBanner(): Boolean +} + +/** + * The default implementation of the [PrivacyNoticeBannerRepository] + */ +class DefaultPrivacyNoticeBannerRepository( + private val settings: Settings, +) : PrivacyNoticeBannerRepository { + override fun updatePrivacyNoticeBannerDisplayedPreference(nowMillis: Long) { + settings.privacyNoticeBannerLastDisplayedTimeInMillis = nowMillis + } + + override fun shouldShowPrivacyNoticeBanner(): Boolean { + return settings.hasAcceptedTermsOfService && + settings.termsOfUseAcceptedTimeInMillis < TOU_TIME_IN_MILLIS && + settings.privacyNoticeBannerLastDisplayedTimeInMillis < TOU_TIME_IN_MILLIS + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/termsofuse/store/PrivacyNoticeBannerStore.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/termsofuse/store/PrivacyNoticeBannerStore.kt @@ -0,0 +1,75 @@ +/* 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.termsofuse.store + +import mozilla.components.lib.state.Action +import mozilla.components.lib.state.Middleware +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store + +/** + * [State] of the Privacy Notice banner. + */ +data class PrivacyNoticeBannerState( + val visible: Boolean, +) : State + +/** + * [Action]s related to the [PrivacyNoticeBannerStore] + */ +sealed interface PrivacyNoticeBannerAction : Action { + /** + * Triggered when the user clicks the close button on the banner. + */ + data object OnCloseClicked : PrivacyNoticeBannerAction + + /** + * Triggered when the user clicks the Privacy Notice link on the banner. + */ + data object OnPrivacyNoticeClicked : PrivacyNoticeBannerAction + + /** + * Triggered when the user clicks the Learn More link on the banner. + */ + data object OnLearnMoreClicked : PrivacyNoticeBannerAction + + /** + * Triggered when the fragment's onStop function is called. + */ + data object OnFragmentStopped : PrivacyNoticeBannerAction + + /** + * Triggered when the banner is displayed. + */ + data object OnBannerDisplayed : PrivacyNoticeBannerAction +} + +/** + * A [Store] that holds the [PrivacyNoticeBannerState] + */ +class PrivacyNoticeBannerStore( + initialState: PrivacyNoticeBannerState, + middleware: List<Middleware<PrivacyNoticeBannerState, PrivacyNoticeBannerAction>>, +) : Store<PrivacyNoticeBannerState, PrivacyNoticeBannerAction>( + initialState = initialState, + reducer = ::reduce, + middleware = middleware, +) + +private fun reduce( + state: PrivacyNoticeBannerState, + action: PrivacyNoticeBannerAction, +): PrivacyNoticeBannerState { + return when (action) { + is PrivacyNoticeBannerAction.OnCloseClicked, + is PrivacyNoticeBannerAction.OnFragmentStopped, + -> state.copy(visible = false) + + is PrivacyNoticeBannerAction.OnLearnMoreClicked, + is PrivacyNoticeBannerAction.OnBannerDisplayed, + is PrivacyNoticeBannerAction.OnPrivacyNoticeClicked, + -> state + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/Settings.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/utils/Settings.kt @@ -600,6 +600,11 @@ class Settings( }, ) + var privacyNoticeBannerLastDisplayedTimeInMillis by longPreference( + key = appContext.getPreferenceKey(R.string.pref_key_privacy_notice_banner_last_displayed_time), + default = 0, + ) + /** * The version of the Terms of Use that the user has accepted. */ 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 @@ -96,6 +96,7 @@ <string name="pref_key_crash_pull_never_show_again" translatable="false">pref_key_crash_pull_never_show_again</string> <string name="pref_key_crash_pull_dont_show_before" translatable="false">pref_key_crash_pull_dont_show_before</string> <string name="pref_key_enable_relay_email_masks" translatable="false">pref_key_enable_relay_email_masks</string> + <string name="pref_key_privacy_notice_banner_last_displayed_time" translatable="false">pref_key_privacy_notice_banner_last_displayed_time</string> <!-- Data Choices --> <string name="pref_key_telemetry" translatable="false">pref_key_telemetry</string> diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/termsofuse/store/PrivacyNoticeBannerMiddlewareTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/termsofuse/store/PrivacyNoticeBannerMiddlewareTest.kt @@ -0,0 +1,80 @@ +/* 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.termsofuse.store + +import io.mockk.mockk +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import mozilla.components.lib.state.MiddlewareContext +import org.junit.Before +import org.junit.Test + +class PrivacyNoticeBannerMiddlewareTest { + + private lateinit var repository: FakePrivacyNoticeBannerRepository + + private val context = + mockk<MiddlewareContext<PrivacyNoticeBannerState, PrivacyNoticeBannerAction>>( + relaxed = true, + ) + + private lateinit var middleware: PrivacyNoticeBannerMiddleware + + @Before + fun setup() { + repository = FakePrivacyNoticeBannerRepository() + middleware = PrivacyNoticeBannerMiddleware(repository) + } + + @Test + fun `WHEN the action OnBannerDisplayed is received THEN we update the privacy notice banner preference`() { + middleware.invoke( + context = context, + next = {}, + action = PrivacyNoticeBannerAction.OnBannerDisplayed, + ) + + assertTrue(repository.updatePrivacyNoticeBannerDisplayedPreferenceCalled) + } + + @Test + fun `WHEN a no-op action is received THEN we do not update the privacy notice banner preference`() { + middleware.invoke( + context = context, + next = {}, + action = PrivacyNoticeBannerAction.OnPrivacyNoticeClicked, + ) + + middleware.invoke( + context = context, + next = {}, + action = PrivacyNoticeBannerAction.OnLearnMoreClicked, + ) + + middleware.invoke( + context = context, + next = {}, + action = PrivacyNoticeBannerAction.OnCloseClicked, + ) + + middleware.invoke( + context = context, + next = {}, + action = PrivacyNoticeBannerAction.OnFragmentStopped, + ) + + assertFalse(repository.updatePrivacyNoticeBannerDisplayedPreferenceCalled) + } + + class FakePrivacyNoticeBannerRepository : PrivacyNoticeBannerRepository { + var updatePrivacyNoticeBannerDisplayedPreferenceCalled = false + + override fun updatePrivacyNoticeBannerDisplayedPreference(nowMillis: Long) { + updatePrivacyNoticeBannerDisplayedPreferenceCalled = true + } + + override fun shouldShowPrivacyNoticeBanner(): Boolean = true + } +} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/termsofuse/store/PrivacyNoticeBannerReducerTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/termsofuse/store/PrivacyNoticeBannerReducerTest.kt @@ -0,0 +1,60 @@ +/* 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.termsofuse.store + +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import org.junit.Test + +class PrivacyNoticeBannerReducerTest { + private lateinit var store: PrivacyNoticeBannerStore + + @Test + fun `WHEN the OnCloseClicked action is received THEN the visibility of the banner is set to false`() { + store = PrivacyNoticeBannerStore( + initialState = PrivacyNoticeBannerState( + visible = true, + ), + middleware = emptyList(), + ) + + store.dispatch(PrivacyNoticeBannerAction.OnCloseClicked) + + assertFalse(store.state.visible) + } + + @Test + fun `WHEN the OnFragmentStopped action is received THEN the visibility of the banner is set to false`() { + store = PrivacyNoticeBannerStore( + initialState = PrivacyNoticeBannerState( + visible = true, + ), + middleware = emptyList(), + ) + + store.dispatch(PrivacyNoticeBannerAction.OnFragmentStopped) + + assertFalse(store.state.visible) + } + + @Test + fun `WHEN a no-op action is received THEN the visibility of the banner does not change`() { + store = PrivacyNoticeBannerStore( + initialState = PrivacyNoticeBannerState( + visible = true, + ), + middleware = emptyList(), + ) + + store.dispatch(PrivacyNoticeBannerAction.OnLearnMoreClicked) + assertTrue(store.state.visible) + + store.dispatch(PrivacyNoticeBannerAction.OnBannerDisplayed) + assertTrue(store.state.visible) + + store.dispatch(PrivacyNoticeBannerAction.OnPrivacyNoticeClicked) + assertTrue(store.state.visible) + } +} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/termsofuse/store/PrivacyNoticeBannerRepositoryTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/termsofuse/store/PrivacyNoticeBannerRepositoryTest.kt @@ -0,0 +1,84 @@ +/* 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.termsofuse.store + +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import mozilla.components.support.test.robolectric.testContext +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.fenix.termsofuse.TOU_TIME_IN_MILLIS +import org.mozilla.fenix.utils.Settings +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class PrivacyNoticeBannerRepositoryTest { + private lateinit var settings: Settings + + private lateinit var repository: DefaultPrivacyNoticeBannerRepository + + @Before + fun setup() { + settings = Settings(testContext) + repository = DefaultPrivacyNoticeBannerRepository(settings) + } + + @Test + fun `WHEN updatePrivacyNoticeBannerSeenPreference is called THEN the preference is updated`() { + repository.updatePrivacyNoticeBannerDisplayedPreference() + + assertTrue(settings.privacyNoticeBannerLastDisplayedTimeInMillis > 0) + } + + @Test + fun `WHEN the user has not accepted the terms of use THEN the banner should not show`() { + settings.hasAcceptedTermsOfService = false + settings.termsOfUseAcceptedTimeInMillis = 0 + settings.privacyNoticeBannerLastDisplayedTimeInMillis = 0 + + assertFalse(repository.shouldShowPrivacyNoticeBanner()) + } + + @Test + fun `WHEN the user has accepted the terms of use after the latest TOU update THEN the banner should not show`() { + settings.hasAcceptedTermsOfService = true + settings.termsOfUseAcceptedTimeInMillis = AFTER_TOU_TIME + settings.privacyNoticeBannerLastDisplayedTimeInMillis = 0 + + assertFalse(repository.shouldShowPrivacyNoticeBanner()) + } + + @Test + fun `WHEN the user has seen the banner after the latest TOU update THEN the banner should not show`() { + settings.hasAcceptedTermsOfService = true + settings.termsOfUseAcceptedTimeInMillis = 0 + settings.privacyNoticeBannerLastDisplayedTimeInMillis = AFTER_TOU_TIME + + assertFalse(repository.shouldShowPrivacyNoticeBanner()) + } + + @Test + fun `WHEN the user has accepted TOU AND there is a new update THEN the banner should show`() { + settings.hasAcceptedTermsOfService = true + settings.termsOfUseAcceptedTimeInMillis = 0 + settings.privacyNoticeBannerLastDisplayedTimeInMillis = 0 + + assertTrue(repository.shouldShowPrivacyNoticeBanner()) + } + + @Test + fun `WHEN the user has accepted TOU and seen the banner after the latest update THEN the banner should not show`() { + settings.hasAcceptedTermsOfService = true + settings.termsOfUseAcceptedTimeInMillis = AFTER_TOU_TIME + settings.privacyNoticeBannerLastDisplayedTimeInMillis = AFTER_TOU_TIME + + assertFalse(repository.shouldShowPrivacyNoticeBanner()) + } + + companion object { + private const val AFTER_TOU_TIME = TOU_TIME_IN_MILLIS + 1_000 + } +}