Skip to content

Commit deee1fa

Browse files
authored
Update dismiss limiter logic for breakage reporting prompts (#5825)
Task/Issue URL: https://app.asana.com/0/0/1209712075249436/f ### Description We'd like to alter the logic for refresh breakage prompts to match iOS in the hopes that this will increase volume similarly. ### Steps to test this PR Notes: Apply patch: https://app.asana.com/app/asana/-/get_asset?asset_id=1209824280082698 Testing values are 7 seconds (between prompts — normally 7 days) and 30 seconds after 3 dismissals (instead of 30 days), so the 3 dismissals in a row need to happen within those 30 seconds. _Between-Prompt Cooldown + Max Dismiss Cooldown Logic_ - [x] Load a site - [x] Refresh 3 times - [x] Accept the prompt - [x] Refresh another 3 times immediately - [x] Verify no prompt (still in cooldown after acceptance) - [x] Refresh 3 more times after 7-second cooldown has passed - [x] Verify prompt shows - [x] Dismiss the prompt - [x] Refresh another 3 times immediately - [x] Verify no prompt (still in cooldown after dismissal) - [x] Refresh 3 more times after 7-second cooldown has passed - [x] Verify prompt shows - [x] Dismiss the prompt - [x] Refresh another 3 times after 7-second cooldown has passed - [x] Verify prompt shows - [x] Dismiss the prompt - [x] After less than 30 seconds refresh another 3 times - [x] Verify no prompt (still in cooldown) - [x] Wait out the rest of the cooldown and refresh 3 more times - [x] Verify prompt shows again
1 parent c111ed4 commit deee1fa

File tree

6 files changed

+177
-130
lines changed

6 files changed

+177
-130
lines changed

broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSitePomptDataStore.kt

Lines changed: 58 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,20 @@ import androidx.datastore.preferences.core.edit
2222
import androidx.datastore.preferences.core.intPreferencesKey
2323
import androidx.datastore.preferences.core.longPreferencesKey
2424
import androidx.datastore.preferences.core.stringPreferencesKey
25+
import androidx.datastore.preferences.core.stringSetPreferencesKey
2526
import com.duckduckgo.app.di.AppCoroutineScope
2627
import com.duckduckgo.brokensite.impl.SharedPreferencesDuckPlayerDataStore.Keys.COOL_DOWN_DAYS
27-
import com.duckduckgo.brokensite.impl.SharedPreferencesDuckPlayerDataStore.Keys.DISMISS_STREAK
28+
import com.duckduckgo.brokensite.impl.SharedPreferencesDuckPlayerDataStore.Keys.DISMISS_EVENTS
2829
import com.duckduckgo.brokensite.impl.SharedPreferencesDuckPlayerDataStore.Keys.DISMISS_STREAK_RESET_DAYS
2930
import com.duckduckgo.brokensite.impl.SharedPreferencesDuckPlayerDataStore.Keys.MAX_DISMISS_STREAK
3031
import com.duckduckgo.brokensite.impl.SharedPreferencesDuckPlayerDataStore.Keys.NEXT_SHOWN_DATE
3132
import com.duckduckgo.brokensite.impl.di.BrokenSitePrompt
3233
import com.duckduckgo.di.scopes.AppScope
3334
import com.squareup.anvil.annotations.ContributesBinding
3435
import dagger.SingleInstanceIn
36+
import java.time.Instant
3537
import java.time.LocalDateTime
38+
import java.time.ZoneId
3639
import java.time.format.DateTimeFormatter
3740
import javax.inject.Inject
3841
import kotlinx.coroutines.CoroutineScope
@@ -41,7 +44,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
4144
import kotlinx.coroutines.flow.first
4245
import kotlinx.coroutines.flow.map
4346

44-
interface BrokenSitePomptDataStore {
47+
interface BrokenSitePromptDataStore {
4548
suspend fun setMaxDismissStreak(maxDismissStreak: Int)
4649
suspend fun getMaxDismissStreak(): Int
4750

@@ -50,8 +53,12 @@ interface BrokenSitePomptDataStore {
5053

5154
suspend fun setCoolDownDays(days: Long)
5255
suspend fun getCoolDownDays(): Long
53-
suspend fun setDismissStreak(streak: Int)
54-
suspend fun getDismissStreak(): Int
56+
57+
suspend fun addDismissal(dismissal: LocalDateTime)
58+
suspend fun clearAllDismissals()
59+
suspend fun getDismissalCountBetween(t1: LocalDateTime, t2: LocalDateTime): Int
60+
suspend fun deleteAllExpiredDismissals(expiryDate: String, zoneId: ZoneId)
61+
5562
suspend fun setNextShownDate(nextShownDate: LocalDateTime?)
5663
suspend fun getNextShownDate(): LocalDateTime?
5764
}
@@ -61,13 +68,13 @@ interface BrokenSitePomptDataStore {
6168
class SharedPreferencesDuckPlayerDataStore @Inject constructor(
6269
@BrokenSitePrompt private val store: DataStore<Preferences>,
6370
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
64-
) : BrokenSitePomptDataStore {
71+
) : BrokenSitePromptDataStore {
6572

6673
private object Keys {
6774
val MAX_DISMISS_STREAK = intPreferencesKey(name = "MAX_DISMISS_STREAK")
6875
val DISMISS_STREAK_RESET_DAYS = intPreferencesKey(name = "DISMISS_STREAK_RESET_DAYS")
6976
val COOL_DOWN_DAYS = longPreferencesKey(name = "COOL_DOWN_DAYS")
70-
val DISMISS_STREAK = intPreferencesKey(name = "DISMISS_STREAK")
77+
val DISMISS_EVENTS = stringSetPreferencesKey(name = "DISMISS_EVENTS")
7178
val NEXT_SHOWN_DATE = stringPreferencesKey(name = "NEXT_SHOWN_DATE")
7279
}
7380

@@ -89,12 +96,6 @@ class SharedPreferencesDuckPlayerDataStore @Inject constructor(
8996
prefs[COOL_DOWN_DAYS] ?: 7
9097
}
9198

92-
private val dismissStreak: Flow<Int> = store.data
93-
.map { prefs ->
94-
prefs[DISMISS_STREAK] ?: 0
95-
}
96-
.distinctUntilChanged()
97-
9899
private val nextShownDate: Flow<String?> = store.data
99100
.map { prefs ->
100101
prefs[NEXT_SHOWN_DATE]
@@ -119,10 +120,6 @@ class SharedPreferencesDuckPlayerDataStore @Inject constructor(
119120

120121
override suspend fun getCoolDownDays(): Long = coolDownDays.first()
121122

122-
override suspend fun setDismissStreak(streak: Int) {
123-
store.edit { prefs -> prefs[DISMISS_STREAK] = streak }
124-
}
125-
126123
override suspend fun setNextShownDate(nextShownDate: LocalDateTime?) {
127124
store.edit { prefs ->
128125

@@ -134,8 +131,51 @@ class SharedPreferencesDuckPlayerDataStore @Inject constructor(
134131
}
135132
}
136133

137-
override suspend fun getDismissStreak(): Int {
138-
return dismissStreak.first()
134+
override suspend fun addDismissal(dismissal: LocalDateTime) {
135+
store.edit { prefs ->
136+
prefs[DISMISS_EVENTS] = (prefs[DISMISS_EVENTS]?.toSet() ?: emptySet()).plus(formatter.format(dismissal))
137+
}
138+
}
139+
140+
override suspend fun clearAllDismissals() {
141+
store.edit { prefs ->
142+
prefs.remove(DISMISS_EVENTS)
143+
}
144+
}
145+
146+
override suspend fun getDismissalCountBetween(
147+
t1: LocalDateTime,
148+
t2: LocalDateTime,
149+
): Int {
150+
val allDismissEvents = store.data.map { prefs ->
151+
prefs[DISMISS_EVENTS]?.toSet() ?: emptySet()
152+
}.first()
153+
154+
return allDismissEvents.count { dateString: String ->
155+
try {
156+
val eventDateTime = LocalDateTime.parse(dateString, formatter)
157+
eventDateTime.isAfter(t1) && eventDateTime.isBefore(t2)
158+
} catch (e: Exception) {
159+
false
160+
}
161+
}
162+
}
163+
164+
override suspend fun deleteAllExpiredDismissals(expiryDate: String, zoneId: ZoneId) {
165+
val expiryInstant = Instant.parse(expiryDate)
166+
167+
store.edit { prefs ->
168+
val allDismissEvents = prefs[DISMISS_EVENTS]?.toSet() ?: emptySet()
169+
170+
val validDismissEvents = allDismissEvents.filterTo(mutableSetOf()) { dateString ->
171+
runCatching {
172+
LocalDateTime.parse(dateString, formatter)
173+
.atZone(zoneId)
174+
.toInstant() > expiryInstant
175+
}.getOrDefault(false)
176+
}
177+
prefs[DISMISS_EVENTS] = validDismissEvents
178+
}
139179
}
140180

141181
override suspend fun getNextShownDate(): LocalDateTime? {

broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/BrokenSiteReportRepository.kt

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import com.duckduckgo.common.utils.sha256
2626
import java.time.Instant
2727
import java.time.LocalDateTime
2828
import java.time.OffsetDateTime
29+
import java.time.ZoneId
2930
import java.time.ZoneOffset
3031
import java.time.format.DateTimeFormatter
3132
import kotlinx.coroutines.CoroutineScope
@@ -52,9 +53,9 @@ interface BrokenSiteReportRepository {
5253
suspend fun setNextShownDate(nextShownDate: LocalDateTime?)
5354
suspend fun getNextShownDate(): LocalDateTime?
5455

55-
suspend fun incrementDismissStreak()
56-
suspend fun getDismissStreak(): Int
57-
suspend fun resetDismissStreak()
56+
suspend fun addDismissal(dismissal: LocalDateTime)
57+
suspend fun clearAllDismissals()
58+
suspend fun getDismissalCountBetween(t1: LocalDateTime, t2: LocalDateTime): Int
5859

5960
fun resetRefreshCount()
6061
fun addRefresh(url: Uri, localDateTime: LocalDateTime)
@@ -65,7 +66,7 @@ class RealBrokenSiteReportRepository(
6566
private val database: BrokenSiteDatabase,
6667
@AppCoroutineScope private val coroutineScope: CoroutineScope,
6768
private val dispatcherProvider: DispatcherProvider,
68-
private val brokenSitePromptDataStore: BrokenSitePomptDataStore,
69+
private val brokenSitePromptDataStore: BrokenSitePromptDataStore,
6970
private val brokenSitePromptInMemoryStore: BrokenSitePromptInMemoryStore,
7071
) : BrokenSiteReportRepository {
7172

@@ -96,6 +97,7 @@ class RealBrokenSiteReportRepository(
9697
coroutineScope.launch(dispatcherProvider.io()) {
9798
val expiryTime = getUTCDate30DaysAgo()
9899
database.brokenSiteDao().deleteAllExpiredReports(expiryTime)
100+
brokenSitePromptDataStore.deleteAllExpiredDismissals(expiryTime, ZoneId.systemDefault())
99101
}
100102
}
101103

@@ -130,25 +132,24 @@ class RealBrokenSiteReportRepository(
130132
setCoolDownDays(coolDownDays.toLong())
131133
}
132134

133-
override suspend fun resetDismissStreak() {
134-
brokenSitePromptDataStore.setDismissStreak(0)
135-
}
136-
137135
override suspend fun setNextShownDate(nextShownDate: LocalDateTime?) {
138136
brokenSitePromptDataStore.setNextShownDate(nextShownDate)
139137
}
140138

141-
override suspend fun getDismissStreak(): Int {
142-
return brokenSitePromptDataStore.getDismissStreak()
143-
}
144-
145139
override suspend fun getNextShownDate(): LocalDateTime? {
146140
return brokenSitePromptDataStore.getNextShownDate()
147141
}
148142

149-
override suspend fun incrementDismissStreak() {
150-
val dismissCount = getDismissStreak()
151-
brokenSitePromptDataStore.setDismissStreak(dismissCount + 1)
143+
override suspend fun addDismissal(dismissal: LocalDateTime) {
144+
brokenSitePromptDataStore.addDismissal(dismissal)
145+
}
146+
147+
override suspend fun clearAllDismissals() {
148+
brokenSitePromptDataStore.clearAllDismissals()
149+
}
150+
151+
override suspend fun getDismissalCountBetween(t1: LocalDateTime, t2: LocalDateTime): Int {
152+
return brokenSitePromptDataStore.getDismissalCountBetween(t1, t2)
152153
}
153154

154155
override fun resetRefreshCount() {

broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/RealBrokenSitePrompt.kt

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -43,23 +43,16 @@ class RealBrokenSitePrompt @Inject constructor(
4343

4444
override suspend fun userDismissedPrompt() {
4545
if (!_featureEnabled) return
46-
if (brokenSiteReportRepository.getDismissStreak() >= brokenSiteReportRepository.getMaxDismissStreak() - 1) {
47-
brokenSiteReportRepository.resetDismissStreak()
48-
val nextShownDate = brokenSiteReportRepository.getNextShownDate()
49-
val newNextShownDate = currentTimeProvider.localDateTimeNow().plusDays(brokenSiteReportRepository.getDismissStreakResetDays().toLong())
5046

51-
if (nextShownDate == null || newNextShownDate.isAfter(nextShownDate)) {
52-
brokenSiteReportRepository.setNextShownDate(newNextShownDate)
53-
}
54-
} else {
55-
brokenSiteReportRepository.incrementDismissStreak()
56-
}
47+
val currentTimestamp = currentTimeProvider.localDateTimeNow()
48+
49+
brokenSiteReportRepository.addDismissal(currentTimestamp)
5750
}
5851

5952
override suspend fun userAcceptedPrompt() {
6053
if (!_featureEnabled) return
6154

62-
brokenSiteReportRepository.resetDismissStreak()
55+
brokenSiteReportRepository.clearAllDismissals()
6356
}
6457

6558
override suspend fun isFeatureEnabled(): Boolean {
@@ -88,17 +81,31 @@ class RealBrokenSitePrompt @Inject constructor(
8881
}
8982

9083
override suspend fun shouldShowBrokenSitePrompt(url: String): Boolean {
91-
return isFeatureEnabled() &&
92-
getUserRefreshesCount() >= REFRESH_COUNT_LIMIT &&
93-
brokenSiteReportRepository.getNextShownDate()?.isBefore(currentTimeProvider.localDateTimeNow()) ?: true &&
94-
!duckGoUrlDetector.isDuckDuckGoUrl(url)
84+
if (!isFeatureEnabled() || getUserRefreshesCount() < REFRESH_COUNT_LIMIT || duckGoUrlDetector.isDuckDuckGoUrl(url)) {
85+
return false
86+
}
87+
88+
val currentTimestamp = currentTimeProvider.localDateTimeNow()
89+
90+
// Check if we're still in a cooldown period
91+
brokenSiteReportRepository.getNextShownDate()?.let { nextDate ->
92+
if (currentTimestamp.isBefore(nextDate)) {
93+
return false
94+
}
95+
}
96+
97+
val dismissStreakResetDays = brokenSiteReportRepository.getDismissStreakResetDays().toLong()
98+
val dismissalCount = brokenSiteReportRepository.getDismissalCountBetween(
99+
currentTimestamp.minusDays(dismissStreakResetDays),
100+
currentTimestamp,
101+
)
102+
103+
return dismissalCount < brokenSiteReportRepository.getMaxDismissStreak()
95104
}
96105

97106
override suspend fun ctaShown() {
98-
val nextShownDate = brokenSiteReportRepository.getNextShownDate()
99-
val newNextShownDate = currentTimeProvider.localDateTimeNow().plusDays(brokenSiteReportRepository.getCoolDownDays())
100-
if (nextShownDate == null || newNextShownDate.isAfter(nextShownDate)) {
101-
brokenSiteReportRepository.setNextShownDate(newNextShownDate)
102-
}
107+
val currentTimestamp = currentTimeProvider.localDateTimeNow()
108+
val newNextShownDate = currentTimestamp.plusDays(brokenSiteReportRepository.getCoolDownDays())
109+
brokenSiteReportRepository.setNextShownDate(newNextShownDate)
103110
}
104111
}

broken-site/broken-site-impl/src/main/java/com/duckduckgo/brokensite/impl/di/BrokenSiteModule.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ package com.duckduckgo.brokensite.impl.di
1919
import android.content.Context
2020
import androidx.room.Room
2121
import com.duckduckgo.app.di.AppCoroutineScope
22-
import com.duckduckgo.brokensite.impl.BrokenSitePomptDataStore
22+
import com.duckduckgo.brokensite.impl.BrokenSitePromptDataStore
2323
import com.duckduckgo.brokensite.impl.BrokenSitePromptInMemoryStore
2424
import com.duckduckgo.brokensite.impl.BrokenSiteReportRepository
2525
import com.duckduckgo.brokensite.impl.RealBrokenSiteReportRepository
@@ -43,7 +43,7 @@ class BrokenSiteModule {
4343
database: BrokenSiteDatabase,
4444
@AppCoroutineScope coroutineScope: CoroutineScope,
4545
dispatcherProvider: DispatcherProvider,
46-
brokenSitePromptDataStore: BrokenSitePomptDataStore,
46+
brokenSitePromptDataStore: BrokenSitePromptDataStore,
4747
brokenSitePromptInMemoryStore: BrokenSitePromptInMemoryStore,
4848
): BrokenSiteReportRepository {
4949
return RealBrokenSiteReportRepository(database, coroutineScope, dispatcherProvider, brokenSitePromptDataStore, brokenSitePromptInMemoryStore)

0 commit comments

Comments
 (0)