From 453fc22d578e6bca4a6fe74499f0e2857ea023d4 Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Fri, 13 Mar 2026 11:52:15 -0400 Subject: [PATCH] [PM-33507] feat: Add premium upgrade banner dismissal persistence (#6657) --- .../datasource/disk/SettingsDiskSource.kt | 18 ++++++++ .../datasource/disk/SettingsDiskSourceImpl.kt | 33 ++++++++++++++ .../platform/repository/SettingsRepository.kt | 10 +++++ .../repository/SettingsRepositoryImpl.kt | 23 ++++++++++ .../datasource/disk/SettingsDiskSourceTest.kt | 45 +++++++++++++++++++ .../disk/util/FakeSettingsDiskSource.kt | 30 +++++++++++++ .../repository/SettingsRepositoryTest.kt | 32 +++++++++++++ 7 files changed, 191 insertions(+) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt index f956ed2b83..0a9561160e 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSource.kt @@ -123,6 +123,24 @@ interface SettingsDiskSource : FlightRecorderDiskSource { */ fun getIntroducingArchiveActionCardDismissedFlow(userId: String): Flow + /** + * Retrieves the stored value of whether the premium upgrade banner has been dismissed. + */ + fun getPremiumUpgradeBannerDismissed(userId: String): Boolean? + + /** + * Stores whether the premium upgrade banner has been dismissed. + */ + fun storePremiumUpgradeBannerDismissed( + userId: String, + isDismissed: Boolean?, + ) + + /** + * Emits updates that track [getPremiumUpgradeBannerDismissed] for the given [userId]. + */ + fun getPremiumUpgradeBannerDismissedFlow(userId: String): Flow + /** * Retrieves the biometric integrity validity for the given [userId] and * [systemBioIntegrityState]. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt index 1c6cc3591a..fb16bc0b66 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceImpl.kt @@ -51,6 +51,8 @@ private const val IS_DYNAMIC_COLORS_ENABLED = "isDynamicColorsEnabled" private const val BROWSER_AUTOFILL_DIALOG_RESHOW_TIME = "browserAutofillDialogReshowTime" private const val INTRODUCING_ARCHIVE_ACTION_CARD_DISMISSED = "introducingArchiveActionCardDismissed" +private const val PREMIUM_UPGRADE_BANNER_DISMISSED = + "premiumUpgradeBannerDismissed" /** * Primary implementation of [SettingsDiskSource]. @@ -92,6 +94,9 @@ class SettingsDiskSourceImpl( private val mutableIntroducingArchiveActionCardDismissedFlowMap = mutableMapOf>() + private val mutablePremiumUpgradeBannerDismissedFlowMap = + mutableMapOf>() + private val mutableIsIconLoadingDisabledFlow = bufferedMutableSharedFlow() private val mutableIsCrashLoggingEnabledFlow = bufferedMutableSharedFlow() @@ -246,6 +251,7 @@ class SettingsDiskSourceImpl( // - should show add login coach mark // - should show generator coach mark // - should show introducing archive action card dismissed + // - premium upgrade banner dismissed } override fun getIntroducingArchiveActionCardDismissed(userId: String): Boolean? = @@ -268,6 +274,26 @@ class SettingsDiskSourceImpl( getMutableIntroducingArchiveActionCardDismissedFlow(userId = userId) .onSubscription { emit(getIntroducingArchiveActionCardDismissed(userId = userId)) } + override fun getPremiumUpgradeBannerDismissed(userId: String): Boolean? = + getBoolean( + key = PREMIUM_UPGRADE_BANNER_DISMISSED.appendIdentifier(identifier = userId), + ) + + override fun storePremiumUpgradeBannerDismissed( + userId: String, + isDismissed: Boolean?, + ) { + putBoolean( + key = PREMIUM_UPGRADE_BANNER_DISMISSED.appendIdentifier(identifier = userId), + value = isDismissed, + ) + getMutablePremiumUpgradeBannerDismissedFlow(userId = userId).tryEmit(isDismissed) + } + + override fun getPremiumUpgradeBannerDismissedFlow(userId: String): Flow = + getMutablePremiumUpgradeBannerDismissedFlow(userId = userId) + .onSubscription { emit(getPremiumUpgradeBannerDismissed(userId = userId)) } + override fun getAccountBiometricIntegrityValidity( userId: String, systemBioIntegrityState: String, @@ -612,6 +638,13 @@ class SettingsDiskSourceImpl( bufferedMutableSharedFlow(replay = 1) } + private fun getMutablePremiumUpgradeBannerDismissedFlow( + userId: String, + ): MutableSharedFlow = + mutablePremiumUpgradeBannerDismissedFlowMap.getOrPut(userId) { + bufferedMutableSharedFlow(replay = 1) + } + private fun getMutableLastSyncFlow( userId: String, ): MutableSharedFlow = diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt index d5fd2355ee..47660290c8 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepository.kt @@ -252,6 +252,16 @@ interface SettingsRepository : FlightRecorderManager { */ fun dismissIntroducingArchiveActionCard() + /** + * Gets updates for whether the premium upgrade banner is dismissed. + */ + fun getPremiumUpgradeBannerDismissedFlow(): StateFlow + + /** + * Stores that the premium upgrade banner has been dismissed for the active user. + */ + fun dismissPremiumUpgradeBanner() + /** * Stores the encrypted user key for biometrics, allowing it to be used to unlock the current * user's vault. diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt index ee678e8428..90253d7d0d 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryImpl.kt @@ -523,6 +523,29 @@ class SettingsRepositoryImpl( } } + override fun getPremiumUpgradeBannerDismissedFlow(): StateFlow { + val userId = activeUserId ?: return MutableStateFlow(value = false) + return settingsDiskSource + .getPremiumUpgradeBannerDismissedFlow(userId = userId) + .map { it ?: false } + .stateIn( + scope = unconfinedScope, + started = SharingStarted.Eagerly, + initialValue = settingsDiskSource + .getPremiumUpgradeBannerDismissed(userId = userId) + ?: false, + ) + } + + override fun dismissPremiumUpgradeBanner() { + activeUserId?.let { + settingsDiskSource.storePremiumUpgradeBannerDismissed( + userId = it, + isDismissed = true, + ) + } + } + override suspend fun setupBiometricsKey(cipher: Cipher): BiometricsKeyResult { val userId = activeUserId ?: return BiometricsKeyResult.Error(error = NoActiveUserException()) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt index 2609a60f3e..a080051f67 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/SettingsDiskSourceTest.kt @@ -142,6 +142,10 @@ class SettingsDiskSourceTest { userId = userId, isDismissed = true, ) + settingsDiskSource.storePremiumUpgradeBannerDismissed( + userId = userId, + isDismissed = true, + ) settingsDiskSource.storeInlineAutofillEnabled( userId = userId, isInlineAutofillEnabled = true, @@ -175,6 +179,9 @@ class SettingsDiskSourceTest { assertTrue( settingsDiskSource.getIntroducingArchiveActionCardDismissed(userId = userId) ?: false, ) + assertTrue( + settingsDiskSource.getPremiumUpgradeBannerDismissed(userId = userId) ?: false, + ) // These should be cleared assertNull(settingsDiskSource.getVaultTimeoutInMinutes(userId = userId)) @@ -824,6 +831,44 @@ class SettingsDiskSourceTest { } } + @Suppress("MaxLineLength") + @Test + fun `getPremiumUpgradeBannerDismissed when values are present should pull from SharedPreferences`() { + val baseKey = "bwPreferencesStorage:premiumUpgradeBannerDismissed" + val mockUserId = "mockUserId" + val key = "${baseKey}_$mockUserId" + assertNull(settingsDiskSource.getPremiumUpgradeBannerDismissed(userId = mockUserId)) + fakeSharedPreferences.edit { putBoolean(key, true) } + assertEquals( + true, + settingsDiskSource.getPremiumUpgradeBannerDismissed(userId = mockUserId), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `getPremiumUpgradeBannerDismissedFlow should react to changes in storePremiumUpgradeBannerDismissed`() = + runTest { + val mockUserId = "mockUserId" + settingsDiskSource + .getPremiumUpgradeBannerDismissedFlow(userId = mockUserId) + .test { + // The initial values of the Flow and the property are in sync + assertNull( + settingsDiskSource + .getPremiumUpgradeBannerDismissed(userId = mockUserId), + ) + assertNull(awaitItem()) + + // Updating the disk source updates shared preferences + settingsDiskSource.storePremiumUpgradeBannerDismissed( + userId = mockUserId, + isDismissed = true, + ) + assertEquals(true, awaitItem()) + } + } + @Test fun `storePullToRefreshEnabled for non-null values should update SharedPreferences`() { val pullToRefreshBaseKey = "bwPreferencesStorage:syncOnRefresh" diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt index fc51671eda..af8e471c16 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/datasource/disk/util/FakeSettingsDiskSource.kt @@ -67,6 +67,7 @@ class FakeSettingsDiskSource( private val storedDisableAutofillSavePrompt = mutableMapOf() private val storedPullToRefreshEnabled = mutableMapOf() private var storedIntroducingArchiveActionCardDismissed = mutableMapOf() + private var storedPremiumUpgradeBannerDismissed = mutableMapOf() private val storedInlineAutofillEnabled = mutableMapOf() private val storedBlockedAutofillUris = mutableMapOf?>() private var storedIsIconLoadingDisabled: Boolean? = null @@ -112,6 +113,9 @@ class FakeSettingsDiskSource( private val mutableIntroducingArchiveActionCardDismissedFlow = mutableMapOf>() + private val mutablePremiumUpgradeBannerDismissedFlow = + mutableMapOf>() + override var appLanguage: AppLanguage? get() = storedAppLanguage set(value) { @@ -341,6 +345,18 @@ class FakeSettingsDiskSource( getMutableIntroducingArchiveActionCardDismissedFlow(userId = userId).tryEmit(isDismissed) } + override fun getPremiumUpgradeBannerDismissed(userId: String): Boolean? = + storedPremiumUpgradeBannerDismissed[userId] + + override fun getPremiumUpgradeBannerDismissedFlow(userId: String): Flow = + getMutablePremiumUpgradeBannerDismissedFlow(userId = userId) + .onSubscription { emit(getPremiumUpgradeBannerDismissed(userId = userId)) } + + override fun storePremiumUpgradeBannerDismissed(userId: String, isDismissed: Boolean?) { + storedPremiumUpgradeBannerDismissed[userId] = isDismissed + getMutablePremiumUpgradeBannerDismissedFlow(userId = userId).tryEmit(isDismissed) + } + override fun getInlineAutofillEnabled(userId: String): Boolean? = storedInlineAutofillEnabled[userId] @@ -498,6 +514,13 @@ class FakeSettingsDiskSource( assertEquals(expected, storedIntroducingArchiveActionCardDismissed[userId]) } + /** + * Asserts that the stored premium upgrade banner dismissed matches the [expected] one. + */ + fun assertPremiumUpgradeBannerDismissed(userId: String, expected: Boolean?) { + assertEquals(expected, storedPremiumUpgradeBannerDismissed[userId]) + } + /** * Asserts that the stored last sync time matches the [expected] one. */ @@ -562,6 +585,13 @@ class FakeSettingsDiskSource( bufferedMutableSharedFlow(replay = 1) } + private fun getMutablePremiumUpgradeBannerDismissedFlow( + userId: String, + ): MutableSharedFlow = + mutablePremiumUpgradeBannerDismissedFlow.getOrPut(userId) { + bufferedMutableSharedFlow(replay = 1) + } + private fun getMutableShowAutoFillSettingBadgeFlow( userId: String, ): MutableSharedFlow = mutableShowAutoFillSettingBadgeFlowMap.getOrPut(userId) { diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt index 7a7d19dc40..e4f0cde4f2 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/platform/repository/SettingsRepositoryTest.kt @@ -886,6 +886,38 @@ class SettingsRepositoryTest { } } + @Test + fun `dismissPremiumUpgradeBanner should properly update SettingsDiskSource`() { + fakeAuthDiskSource.userState = MOCK_USER_STATE + settingsRepository.dismissPremiumUpgradeBanner() + assertEquals( + true, + fakeSettingsDiskSource.getPremiumUpgradeBannerDismissed(userId = USER_ID), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `getPremiumUpgradeBannerDismissedFlow should react to changes in SettingsDiskSource`() = + runTest { + fakeAuthDiskSource.userState = MOCK_USER_STATE + settingsRepository + .getPremiumUpgradeBannerDismissedFlow() + .test { + assertFalse(awaitItem()) + fakeSettingsDiskSource.storePremiumUpgradeBannerDismissed( + userId = USER_ID, + isDismissed = true, + ) + assertTrue(awaitItem()) + fakeSettingsDiskSource.storePremiumUpgradeBannerDismissed( + userId = USER_ID, + isDismissed = false, + ) + assertFalse(awaitItem()) + } + } + @Test fun `storePullToRefreshEnabled should properly update SettingsDiskSource`() { fakeAuthDiskSource.userState = MOCK_USER_STATE