Skip to content

Commit

Permalink
Additional "default browser" prompts: fixed an issue with active days…
Browse files Browse the repository at this point in the history
  • Loading branch information
LukasPaczos authored Feb 7, 2025
1 parent 2bcb0e8 commit 748ae69
Show file tree
Hide file tree
Showing 9 changed files with 305 additions and 40 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,14 @@ import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.DefaultBrowserPr
import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.DefaultBrowserPromptsDataStore.ExperimentStage.STAGE_1
import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.DefaultBrowserPromptsDataStore.ExperimentStage.STAGE_2
import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.DefaultBrowserPromptsDataStore.ExperimentStage.STOPPED
import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.ExperimentAppUsageRepository
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.global.DefaultRoleBrowserDialog
import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver
import com.duckduckgo.app.onboarding.store.AppStage
import com.duckduckgo.app.onboarding.store.UserStageStore
import com.duckduckgo.app.pixels.AppPixelName
import com.duckduckgo.app.statistics.pixels.Pixel
import com.duckduckgo.app.usage.app.AppDaysUsedRepository
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.common.utils.plugins.PluginPoint
import com.duckduckgo.di.scopes.AppScope
Expand All @@ -54,8 +54,6 @@ import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.anvil.annotations.ContributesMultibinding
import com.squareup.moshi.Moshi
import dagger.SingleInstanceIn
import java.time.ZonedDateTime
import java.util.Date
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
Expand Down Expand Up @@ -99,7 +97,7 @@ class DefaultBrowserPromptsExperimentImpl @Inject constructor(
private val defaultBrowserPromptsFeatureToggles: DefaultBrowserPromptsFeatureToggles,
private val defaultBrowserDetector: DefaultBrowserDetector,
private val defaultRoleBrowserDialog: DefaultRoleBrowserDialog,
private val appDaysUsedRepository: AppDaysUsedRepository,
private val experimentAppUsageRepository: ExperimentAppUsageRepository,
private val userStageStore: UserStageStore,
private val defaultBrowserPromptsDataStore: DefaultBrowserPromptsDataStore,
private val experimentStageEvaluatorPluginPoint: PluginPoint<DefaultBrowserPromptsExperimentStageEvaluator>,
Expand Down Expand Up @@ -167,6 +165,7 @@ class DefaultBrowserPromptsExperimentImpl @Inject constructor(
override fun onResume(owner: LifecycleOwner) {
super.onResume(owner)
appCoroutineScope.launch {
experimentAppUsageRepository.recordAppUsedNow()
evaluate()
}
}
Expand Down Expand Up @@ -206,12 +205,10 @@ class DefaultBrowserPromptsExperimentImpl @Inject constructor(
} else if (isDefaultBrowser) {
CONVERTED
} else {
/**
* The [appDaysUsedRepository] expects a [Date] but the experiment framework stores the enrollment date as [ZonedDateTime],
* so we're doing a conversion here.
*/
val enrollmentDateGMT = defaultBrowserPromptsFeatureToggles.getEnrollmentDate() ?: run {
Timber.e("Missing enrollment date even though cohort is assigned.")
val appActiveDaysUsedSinceEnrollment = experimentAppUsageRepository.getActiveDaysUsedSinceEnrollment(
defaultBrowserPromptsFeatureToggles.defaultBrowserAdditionalPrompts202501(),
).getOrElse { throwable ->
Timber.e(throwable)
return
}

Expand All @@ -229,23 +226,23 @@ class DefaultBrowserPromptsExperimentImpl @Inject constructor(
NOT_ENROLLED -> ENROLLED

ENROLLED -> {
if (appDaysUsedRepository.getNumberOfDaysAppUsedSinceDate(enrollmentDateGMT) >= configSettings.activeDaysUntilStage1) {
if (appActiveDaysUsedSinceEnrollment >= configSettings.activeDaysUntilStage1) {
STAGE_1
} else {
null
}
}

STAGE_1 -> {
if (appDaysUsedRepository.getNumberOfDaysAppUsedSinceDate(enrollmentDateGMT) >= configSettings.activeDaysUntilStage2) {
if (appActiveDaysUsedSinceEnrollment >= configSettings.activeDaysUntilStage2) {
STAGE_2
} else {
null
}
}

STAGE_2 -> {
if (appDaysUsedRepository.getNumberOfDaysAppUsedSinceDate(enrollmentDateGMT) >= configSettings.activeDaysUntilStop) {
if (appActiveDaysUsedSinceEnrollment >= configSettings.activeDaysUntilStop) {
STOPPED
} else {
null
Expand Down Expand Up @@ -380,12 +377,6 @@ class DefaultBrowserPromptsExperimentImpl @Inject constructor(
}
}

private fun DefaultBrowserPromptsFeatureToggles.getEnrollmentDate(): Date? =
defaultBrowserAdditionalPrompts202501().getCohort()?.enrollmentDateET?.let { enrollmentZonedDateET ->
val instant = ZonedDateTime.parse(enrollmentZonedDateET).toInstant()
return Date.from(instant)
}

private fun DefaultBrowserPromptsFeatureToggles.getOrAssignCohort(): AdditionalPromptsCohortName? {
for (cohort in AdditionalPromptsCohortName.entries) {
if (defaultBrowserAdditionalPrompts202501().isEnabled(cohort)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import com.duckduckgo.app.global.db.AppDatabase
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
Expand All @@ -28,15 +29,18 @@ import javax.inject.Qualifier

@ContributesTo(AppScope::class)
@Module
object DefaultBrowserPromptsDataStoreModule {
object DefaultBrowserPromptsExperimentModule {

private val Context.defaultBrowserPromptsDataStore: DataStore<Preferences> by preferencesDataStore(
name = "default_browser_prompts",
)

@Provides
@DefaultBrowserPrompts
fun defaultBrowserPromptsDataStore(context: Context): DataStore<Preferences> = context.defaultBrowserPromptsDataStore
fun providesDefaultBrowserPromptsDataStore(context: Context): DataStore<Preferences> = context.defaultBrowserPromptsDataStore

@Provides
fun providesExperimentAppUsageDao(database: AppDatabase) = database.experimentAppUsageDao()
}

@Qualifier
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2025 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.app.browser.defaultbrowsing.prompts.store

import com.duckduckgo.feature.toggles.api.Toggle
import com.duckduckgo.feature.toggles.api.Toggle.State.Cohort
import java.time.format.DateTimeParseException

/**
* Tracks the days app is used to leverage for purposes related to experiments run with the [NA Experiment Framework](https://app.asana.com/0/1208889145294658/1208889101183474).
*
* See [Cohort.enrollmentDateET] for how the framework stores enrollment dates.
*/
interface ExperimentAppUsageRepository {

suspend fun recordAppUsedNow()

/**
* Returns the number of active days the app has been used since enrollment.
*
* Crossing a dateline in local time will not increment the returned count.
* Only if a given instant crossed dateline in ET timezone, the value will be incremented.
*
* @return Count if successful.
* [UserNotEnrolledException] if the given feature is disable or user is not assigned to a cohort.
* [DateTimeParseException] if the enrollment date is malformed.
*/
suspend fun getActiveDaysUsedSinceEnrollment(toggle: Toggle): Result<Long>

class UserNotEnrolledException : Exception()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/*
* Copyright (c) 2025 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.app.browser.defaultbrowsing.prompts.store

import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.ExperimentAppUsageRepository.UserNotEnrolledException
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.feature.toggles.api.Toggle
import com.squareup.anvil.annotations.ContributesBinding
import dagger.SingleInstanceIn
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
import java.time.temporal.ChronoUnit
import javax.inject.Inject
import kotlinx.coroutines.withContext

@ContributesBinding(scope = AppScope::class)
@SingleInstanceIn(scope = AppScope::class)
class ExperimentAppUsageRepositoryImpl @Inject constructor(
private val dispatchers: DispatcherProvider,
private val experimentAppUsageDao: ExperimentAppUsageDao,
) : ExperimentAppUsageRepository {

override suspend fun recordAppUsedNow() = withContext(dispatchers.io()) {
val isoDateET = ZonedDateTime.now(ZoneId.of("America/New_York"))
.truncatedTo(ChronoUnit.DAYS)
.format(DateTimeFormatter.ISO_LOCAL_DATE)

experimentAppUsageDao.insert(ExperimentAppUsageEntity(isoDateET))
}

override suspend fun getActiveDaysUsedSinceEnrollment(toggle: Toggle): Result<Long> = withContext(dispatchers.io()) {
toggle.getCohort()?.enrollmentDateET?.let { enrollmentZonedDateTimeETString ->
try {
val isoDateET = ZonedDateTime.parse(enrollmentZonedDateTimeETString)
.truncatedTo(ChronoUnit.DAYS)
.format(DateTimeFormatter.ISO_LOCAL_DATE)

val daysUsed = experimentAppUsageDao.getNumberOfDaysAppUsedSinceDateET(isoDateET)
Result.success(daysUsed)
} catch (ex: DateTimeParseException) {
Result.failure(ex)
}
} ?: Result.failure(UserNotEnrolledException())
}
}

@Dao
abstract class ExperimentAppUsageDao {

@Insert(onConflict = OnConflictStrategy.IGNORE)
abstract fun insert(experimentAppUsageEntity: ExperimentAppUsageEntity)

@Query("SELECT COUNT(*) from experiment_app_usage_entity WHERE isoDateET > :isoDateET")
abstract fun getNumberOfDaysAppUsedSinceDateET(isoDateET: String): Long
}

@Entity(tableName = "experiment_app_usage_entity")
data class ExperimentAppUsageEntity(@PrimaryKey val isoDateET: String)
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import androidx.sqlite.db.SupportSQLiteDatabase
import com.duckduckgo.app.bookmarks.db.*
import com.duckduckgo.app.browser.cookies.db.AuthCookieAllowedDomainEntity
import com.duckduckgo.app.browser.cookies.db.AuthCookiesAllowedDomainsDao
import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.ExperimentAppUsageDao
import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.ExperimentAppUsageEntity
import com.duckduckgo.app.browser.pageloadpixel.PageLoadedPixelDao
import com.duckduckgo.app.browser.pageloadpixel.PageLoadedPixelEntity
import com.duckduckgo.app.browser.pageloadpixel.firstpaint.PagePaintedPixelDao
Expand Down Expand Up @@ -73,7 +75,7 @@ import com.duckduckgo.savedsites.store.SavedSitesRelationsDao

@Database(
exportSchema = true,
version = 56,
version = 57,
entities = [
TdsTracker::class,
TdsEntity::class,
Expand Down Expand Up @@ -107,6 +109,7 @@ import com.duckduckgo.savedsites.store.SavedSitesRelationsDao
Entity::class,
Relation::class,
RefreshEntity::class,
ExperimentAppUsageEntity::class,
],
)

Expand Down Expand Up @@ -161,6 +164,8 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun syncRelationsDao(): SavedSitesRelationsDao

abstract fun refreshDao(): RefreshDao

abstract fun experimentAppUsageDao(): ExperimentAppUsageDao
}

@Suppress("PropertyName")
Expand Down
Loading

0 comments on commit 748ae69

Please sign in to comment.