[PM-33507] feat: Add premium upgrade banner dismissal persistence (#6657)

This commit is contained in:
Patrick Honkonen
2026-03-13 11:52:15 -04:00
committed by GitHub
parent 93a3e0af32
commit 453fc22d57
7 changed files with 191 additions and 0 deletions

View File

@@ -123,6 +123,24 @@ interface SettingsDiskSource : FlightRecorderDiskSource {
*/
fun getIntroducingArchiveActionCardDismissedFlow(userId: String): Flow<Boolean?>
/**
* 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<Boolean?>
/**
* Retrieves the biometric integrity validity for the given [userId] and
* [systemBioIntegrityState].

View File

@@ -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<String, MutableSharedFlow<Boolean?>>()
private val mutablePremiumUpgradeBannerDismissedFlowMap =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutableIsIconLoadingDisabledFlow = bufferedMutableSharedFlow<Boolean?>()
private val mutableIsCrashLoggingEnabledFlow = bufferedMutableSharedFlow<Boolean?>()
@@ -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<Boolean?> =
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<Boolean?> =
mutablePremiumUpgradeBannerDismissedFlowMap.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableLastSyncFlow(
userId: String,
): MutableSharedFlow<Instant?> =

View File

@@ -252,6 +252,16 @@ interface SettingsRepository : FlightRecorderManager {
*/
fun dismissIntroducingArchiveActionCard()
/**
* Gets updates for whether the premium upgrade banner is dismissed.
*/
fun getPremiumUpgradeBannerDismissedFlow(): StateFlow<Boolean>
/**
* 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.

View File

@@ -523,6 +523,29 @@ class SettingsRepositoryImpl(
}
}
override fun getPremiumUpgradeBannerDismissedFlow(): StateFlow<Boolean> {
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())

View File

@@ -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"

View File

@@ -67,6 +67,7 @@ class FakeSettingsDiskSource(
private val storedDisableAutofillSavePrompt = mutableMapOf<String, Boolean?>()
private val storedPullToRefreshEnabled = mutableMapOf<String, Boolean?>()
private var storedIntroducingArchiveActionCardDismissed = mutableMapOf<String, Boolean?>()
private var storedPremiumUpgradeBannerDismissed = mutableMapOf<String, Boolean?>()
private val storedInlineAutofillEnabled = mutableMapOf<String, Boolean?>()
private val storedBlockedAutofillUris = mutableMapOf<String, List<String>?>()
private var storedIsIconLoadingDisabled: Boolean? = null
@@ -112,6 +113,9 @@ class FakeSettingsDiskSource(
private val mutableIntroducingArchiveActionCardDismissedFlow =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
private val mutablePremiumUpgradeBannerDismissedFlow =
mutableMapOf<String, MutableSharedFlow<Boolean?>>()
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<Boolean?> =
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<Boolean?> =
mutablePremiumUpgradeBannerDismissedFlow.getOrPut(userId) {
bufferedMutableSharedFlow(replay = 1)
}
private fun getMutableShowAutoFillSettingBadgeFlow(
userId: String,
): MutableSharedFlow<Boolean?> = mutableShowAutoFillSettingBadgeFlowMap.getOrPut(userId) {

View File

@@ -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