Skip to content

Commit

Permalink
Additional "default browser" prompts: enroll only onboarded users (#5523
Browse files Browse the repository at this point in the history
  • Loading branch information
LukasPaczos authored Jan 31, 2025
1 parent b505da1 commit 324f597
Show file tree
Hide file tree
Showing 8 changed files with 276 additions and 56 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* 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.onboarding.store

import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import app.cash.turbine.test
import com.duckduckgo.app.global.db.AppDatabase
import com.duckduckgo.common.test.CoroutineTestRule
import java.io.IOException
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class AppUserStageStoreTest {
private lateinit var userStageDao: UserStageDao
private lateinit var db: AppDatabase

@get:Rule
var coroutineRule = CoroutineTestRule()

@Before
fun createDb() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build()
userStageDao = db.userStageDao()
}

@After
@Throws(IOException::class)
fun closeDb() {
db.close()
}

@Test
fun whenStageObserverAttachedThenNullReturnedByDefault() = runTest {
assertNull(userStageDao.currentUserAppStageFlow().first())
}

@Test
fun whenStageUpdatedThenNotifyObservers() = runTest {
val expected = UserStage(appStage = AppStage.ESTABLISHED)

userStageDao.insert(expected)

userStageDao.currentUserAppStageFlow().test {
assertEquals(expected, awaitItem())
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import com.duckduckgo.app.browser.defaultbrowsing.prompts.store.DefaultBrowserPr
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.usage.app.AppDaysUsedRepository
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.common.utils.plugins.PluginPoint
Expand Down Expand Up @@ -90,6 +92,7 @@ class DefaultBrowserPromptsExperimentImpl @Inject constructor(
private val defaultBrowserDetector: DefaultBrowserDetector,
private val defaultRoleBrowserDialog: DefaultRoleBrowserDialog,
private val appDaysUsedRepository: AppDaysUsedRepository,
private val userStageStore: UserStageStore,
private val defaultBrowserPromptsDataStore: DefaultBrowserPromptsDataStore,
private val experimentStageEvaluatorPluginPoint: PluginPoint<DefaultBrowserPromptsExperimentStageEvaluator>,
moshi: Moshi,
Expand Down Expand Up @@ -143,6 +146,14 @@ class DefaultBrowserPromptsExperimentImpl @Inject constructor(
*/
private var browserSelectionWindowFallbackDeferred: Deferred<Unit>? = null

init {
appCoroutineScope.launch {
userStageStore.userAppStageFlow().collect {
evaluate()
}
}
}

override fun onResume(owner: LifecycleOwner) {
super.onResume(owner)
appCoroutineScope.launch {
Expand All @@ -158,9 +169,10 @@ class DefaultBrowserPromptsExperimentImpl @Inject constructor(
}

private suspend fun evaluate() = evaluationMutex.withLock {
val isOnboardingComplete = userStageStore.getUserAppStage() == AppStage.ESTABLISHED
val isEnrolled = defaultBrowserPromptsFeatureToggles.defaultBrowserAdditionalPrompts202501().getCohort() != null
val isDefaultBrowser = defaultBrowserDetector.isDefaultBrowser()
val isEligible = isEnrolled || !isDefaultBrowser
val isEligible = isOnboardingComplete && (isEnrolled || !isDefaultBrowser)
if (!isEligible) {
return
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ class DefaultBrowserPromptsExperimentImpl {
- val defaultBrowserDetector: DefaultBrowserDetector
- val defaultRoleBrowserDialog: DefaultRoleBrowserDialog
- val appDaysUsedRepository: AppDaysUsedRepository
- val userStageStore: UserStageStore
- val defaultBrowserPromptsDataStore: DefaultBrowserPromptsDataStore
- val experimentStageEvaluatorPluginPoint: PluginPoint<DefaultBrowserPromptsExperimentStageEvaluator>
- val pixelSender: PixelSender
Expand Down Expand Up @@ -179,6 +180,10 @@ note left of DefaultBrowserPromptsExperimentImpl::appDaysUsedRepository
Used to monitor the active use days.
end note

note left of DefaultBrowserPromptsExperimentImpl::userStageStore
Used to check if user is onboarded
end note

note left of DefaultBrowserPromptsExperimentImpl::defaultBrowserPromptsDataStore
Used to persist the stage of the experiment.
end note
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,64 +4,67 @@ note
Flow run each time when user opens or comes back to the app.
end note
:App process resumed or remote config loaded;
if (Is enrolled OR does not have DDG set as default browser) then (yes)
if (Has user converted already?) then (no)
if (Is user in the `variant_2` cohort?) then (yes)
note
Also enrolls and assigns the cohort, if needed.
end note
if (Is DDG the default browser app) then (no)
switch (Experiment stage)
case (NOT_ENROLLED)
:Enroll and assign a cohort;
:Move stage to ENROLLED;
case (ENROLLED)
if (App active use days since enrollment >= 1?) then (yes)
:Move stage to STAGE_1;
:Show message dialog;
if (Is onboarded?) then (yes)
if (Is enrolled OR does not have DDG set as default browser?) then (yes)
if (Has user converted already?) then (no)
if (Is user in the `variant_2` cohort?) then (yes)
note
Also enrolls and assigns the cohort, if needed.
end note
if (Is DDG the default browser app) then (no)
switch (Experiment stage)
case (NOT_ENROLLED)
:Enroll and assign a cohort;
:Move stage to ENROLLED;
case (ENROLLED)
if (App active use days since enrollment >= 1?) then (yes)
:Move stage to STAGE_1;
:Show message dialog;
else (no)
endif
case (STAGE_1)
if (App active use days since enrollment >= 20?) then (yes)
:Move stage to STAGE_2;
:Show popup menu highlight;
:Show popup menu item;
else (no)
endif
case (STAGE_2)
if (App active use days since enrollment >= 30?) then (yes)
:Move stage to STOPPED;
:Remove popup menu highlight;
:Remove popup menu item;
else (no)
endif
case (STOPPED)
:noop;
case (CONVERTED)
:noop;
endswitch
stop
else (yes)
if (Is STOPPED) is (yes) then
else (no)
:Move stage to CONVERTED;
:Send conversion pixel;
endif
case (STAGE_1)
if (App active use days since enrollment >= 20?) then (yes)
:Move stage to STAGE_2;
:Show popup menu highlight;
:Show popup menu item;
else (no)
endif
case (STAGE_2)
if (App active use days since enrollment >= 30?) then (yes)
:Move stage to STOPPED;
:Remove popup menu highlight;
:Remove popup menu item;
else (no)
endif
case (STOPPED)
:noop;
case (CONVERTED)
:noop;
endswitch
stop
else (yes)
if (Is STOPPED) is (yes) then
endif
else (no)
note right
If experiment was underway but we lost the cohort name,
it means that the experiment was remotely disabled.
end note
if (Was enrolled already?) is (yes) then
:Move stage to STOPPED;
:Remove popup menu highlight;
:Remove popup menu item;
else (no)
:Move stage to CONVERTED;
:Send conversion pixel;
endif
endif
else (no)
note right
If experiment was underway but we lost the cohort name,
it means that the experiment was remotely disabled.
end note
if (Was enrolled already?) is (yes) then
:Move stage to STOPPED;
:Remove popup menu highlight;
:Remove popup menu item;
else (no)
else (yes)
endif
endif
else (yes)
endif
else (false)
endif
else (false)
endif
stop
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,14 @@
package com.duckduckgo.app.onboarding.store

import androidx.room.*
import kotlinx.coroutines.flow.Flow

@Dao
interface UserStageDao {

@Query("select * from $USER_STAGE_TABLE_NAME limit 1")
fun currentUserAppStageFlow(): Flow<UserStage?>

@Query("select * from $USER_STAGE_TABLE_NAME limit 1")
suspend fun currentUserAppStage(): UserStage?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,12 @@ package com.duckduckgo.app.onboarding.store

import com.duckduckgo.common.utils.DispatcherProvider
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext

interface UserStageStore {
fun userAppStageFlow(): Flow<AppStage>
suspend fun getUserAppStage(): AppStage
suspend fun stageCompleted(appStage: AppStage): AppStage
suspend fun moveToStage(appStage: AppStage)
Expand All @@ -31,6 +34,10 @@ class AppUserStageStore @Inject constructor(
private val dispatcher: DispatcherProvider,
) : UserStageStore {

override fun userAppStageFlow(): Flow<AppStage> {
return userStageDao.currentUserAppStageFlow().map { it?.appStage ?: AppStage.NEW }
}

override suspend fun getUserAppStage(): AppStage {
return withContext(dispatcher.io()) {
val userStage = userStageDao.currentUserAppStage()
Expand Down
Loading

0 comments on commit 324f597

Please sign in to comment.