Skip to content

Commit f8144fa

Browse files
authored
initialize VisualDesignExperimentDataStore on a worker thread (#6181)
Task/Issue URL: https://app.asana.com/1/137249556945/project/1208671518894266/task/1210355090527279 ### Description 1. `LaunchBridgeActivity` now extends `DaggerActivity` directly instead of `DuckDuckGoActivity`. Since it doesn't include any styleable elements, it doesn't need to load the app theme. This allows the visual design data store - needed later to retrieve the theme instance - to be prepared asynchronously. 2. `VisualDesignExperimentDataStoreImpl` is now wrapped in `VisualDesignExperimentDataStoreLazyProvider`, which adds an initialize function. This allows `LaunchBridgeActivity` to pre-warm the data store while the splash screen is visible, before launching other activities. Together, these changes fully move `VisualDesignExperimentDataStore` initialization to a worker thread, avoiding any feature flag reads or iterations on the main thread. ### Steps to test this PR - [x] Launch the app and verify the production theme is loaded. - [x] Change configuration to `https://www.jsonblob.com/api/1379819763261431808` and verify that the new visual design is loaded, without any flickers.
1 parent 94c9202 commit f8144fa

File tree

6 files changed

+301
-21
lines changed

6 files changed

+301
-21
lines changed

app/src/main/java/com/duckduckgo/app/browser/omnibar/experiments/FadeOmnibarLayout.kt

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ import com.duckduckgo.app.browser.omnibar.Omnibar.ViewMode
4444
import com.duckduckgo.app.browser.omnibar.OmnibarLayout
4545
import com.duckduckgo.app.browser.omnibar.OmnibarLayoutViewModel.ViewState
4646
import com.duckduckgo.app.browser.omnibar.model.OmnibarPosition
47-
import com.duckduckgo.common.ui.experiments.visual.store.VisualDesignExperimentDataStore
4847
import com.duckduckgo.common.ui.view.gone
4948
import com.duckduckgo.common.ui.view.hide
5049
import com.duckduckgo.common.ui.view.show
@@ -64,9 +63,6 @@ class FadeOmnibarLayout @JvmOverloads constructor(
6463
defStyle: Int = 0,
6564
) : OmnibarLayout(context, attrs, defStyle) {
6665

67-
@Inject
68-
lateinit var experimentDataStore: VisualDesignExperimentDataStore
69-
7066
@Inject
7167
lateinit var globalActivityStarter: GlobalActivityStarter
7268

app/src/main/java/com/duckduckgo/app/launch/LaunchBridgeActivity.kt

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,26 +20,35 @@ import android.os.Bundle
2020
import androidx.activity.result.ActivityResult
2121
import androidx.activity.result.contract.ActivityResultContracts
2222
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
23+
import androidx.lifecycle.ViewModelProvider
2324
import androidx.lifecycle.lifecycleScope
2425
import com.duckduckgo.anvil.annotations.InjectWith
2526
import com.duckduckgo.app.browser.BrowserActivity
2627
import com.duckduckgo.app.browser.R
2728
import com.duckduckgo.app.onboarding.ui.OnboardingActivity
28-
import com.duckduckgo.common.ui.DuckDuckGoActivity
29+
import com.duckduckgo.common.ui.experiments.visual.store.VisualDesignExperimentDataStoreInitializer
2930
import com.duckduckgo.daxprompts.api.DaxPromptBrowserComparisonNoParams
3031
import com.duckduckgo.daxprompts.api.DaxPromptDuckPlayerNoParams
3132
import com.duckduckgo.daxprompts.impl.ui.DaxPromptBrowserComparisonActivity.Companion.DAX_PROMPT_BROWSER_COMPARISON_SET_DEFAULT_EXTRA
3233
import com.duckduckgo.daxprompts.impl.ui.DaxPromptDuckPlayerActivity.Companion.DAX_PROMPT_DUCK_PLAYER_ACTIVITY_URL_EXTRA
3334
import com.duckduckgo.di.scopes.ActivityScope
3435
import com.duckduckgo.navigation.api.GlobalActivityStarter
36+
import dagger.android.AndroidInjection
37+
import dagger.android.DaggerActivity
3538
import javax.inject.Inject
3639
import kotlinx.coroutines.launch
3740
import logcat.logcat
3841

3942
@InjectWith(ActivityScope::class)
40-
class LaunchBridgeActivity : DuckDuckGoActivity() {
43+
class LaunchBridgeActivity : DaggerActivity() {
4144

42-
private val viewModel: LaunchViewModel by bindViewModel()
45+
@Inject lateinit var visualDesignExperimentDataStoreInitializer: VisualDesignExperimentDataStoreInitializer
46+
47+
@Inject lateinit var viewModelFactory: ViewModelProvider.NewInstanceFactory
48+
49+
private val viewModel: LaunchViewModel by lazy {
50+
ViewModelProvider(this, viewModelFactory)[LaunchViewModel::class.java]
51+
}
4352

4453
private val startDaxPromptDuckPlayerActivityForResult =
4554
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
@@ -69,6 +78,7 @@ class LaunchBridgeActivity : DuckDuckGoActivity() {
6978
lateinit var globalActivityStarter: GlobalActivityStarter
7079

7180
override fun onCreate(savedInstanceState: Bundle?) {
81+
AndroidInjection.inject(this, bindingKey = DaggerActivity::class.java)
7282
val splashScreen = installSplashScreen()
7383
super.onCreate(savedInstanceState)
7484
splashScreen.setKeepOnScreenCondition { true }
@@ -77,7 +87,10 @@ class LaunchBridgeActivity : DuckDuckGoActivity() {
7787

7888
configureObservers()
7989

80-
lifecycleScope.launch { viewModel.determineViewToShow() }
90+
lifecycleScope.launch {
91+
visualDesignExperimentDataStoreInitializer.initialize()
92+
viewModel.determineViewToShow()
93+
}
8194
}
8295

8396
private fun configureObservers() {

common/common-ui/src/main/java/com/duckduckgo/common/ui/experiments/visual/store/VisualDesignExperimentDataStore.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,15 @@ package com.duckduckgo.common.ui.experiments.visual.store
1818

1919
import kotlinx.coroutines.flow.StateFlow
2020

21+
interface VisualDesignExperimentDataStoreInitializer {
22+
/**
23+
* Initializes the [VisualDesignExperimentDataStore].
24+
*
25+
* This is a special, idempotent function to be called while the splash screen is visible to pre-warm the store and allow for non-blocking, synchronous access afterwards.
26+
*/
27+
suspend fun initialize(): VisualDesignExperimentDataStore
28+
}
29+
2130
interface VisualDesignExperimentDataStore {
2231

2332
/**

common/common-ui/src/main/java/com/duckduckgo/common/ui/experiments/visual/store/VisualDesignExperimentDataStoreImpl.kt

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,6 @@ import com.duckduckgo.feature.toggles.api.FeatureTogglesInventory
2424
import com.duckduckgo.feature.toggles.api.Toggle
2525
import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin
2626
import com.squareup.anvil.annotations.ContributesBinding
27-
import com.squareup.anvil.annotations.ContributesMultibinding
28-
import dagger.SingleInstanceIn
2927
import javax.inject.Inject
3028
import kotlinx.coroutines.CoroutineScope
3129
import kotlinx.coroutines.flow.MutableStateFlow
@@ -37,13 +35,7 @@ import kotlinx.coroutines.flow.stateIn
3735
import kotlinx.coroutines.launch
3836
import kotlinx.coroutines.runBlocking
3937

40-
@ContributesBinding(
41-
scope = AppScope::class,
42-
boundType = VisualDesignExperimentDataStore::class,
43-
)
44-
@ContributesMultibinding(scope = AppScope::class, boundType = PrivacyConfigCallbackPlugin::class)
45-
@SingleInstanceIn(scope = AppScope::class)
46-
class VisualDesignExperimentDataStoreImpl @Inject constructor(
38+
class VisualDesignExperimentDataStoreImpl(
4739
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
4840
private val experimentalUIThemingFeature: ExperimentalUIThemingFeature,
4941
private val featureTogglesInventory: FeatureTogglesInventory,
@@ -91,10 +83,6 @@ class VisualDesignExperimentDataStoreImpl @Inject constructor(
9183
initialValue = _duckAIFeatureFlagEnabled.value && isExperimentEnabled.value,
9284
)
9385

94-
/**
95-
* This is a blocking call but it only blocks the main thread when the class initializes, so when the splash screen is visible.
96-
* All subsequent calls are moved off of the main thread.
97-
*/
9886
private fun isAnyConflictingExperimentEnabled(): Boolean = runBlocking {
9987
val activeExperimentsNames = featureTogglesInventory.getAllActiveExperimentToggles().map { it.featureName().name }
10088
conflictingExperimentsNames.any { activeExperimentsNames.contains(it) }
@@ -127,3 +115,30 @@ class VisualDesignExperimentDataStoreImpl @Inject constructor(
127115
}
128116
}
129117
}
118+
119+
/**
120+
* Factory for integration with [VisualDesignExperimentDataStoreLazyProvider] and testing purposes,
121+
* to get an actual instance for your use case just inject [VisualDesignExperimentDataStore].
122+
*/
123+
interface VisualDesignExperimentDataStoreImplFactory {
124+
fun create(
125+
appCoroutineScope: CoroutineScope,
126+
experimentalUIThemingFeature: ExperimentalUIThemingFeature,
127+
featureTogglesInventory: FeatureTogglesInventory,
128+
): VisualDesignExperimentDataStoreImpl
129+
}
130+
131+
@ContributesBinding(scope = AppScope::class)
132+
class VisualDesignExperimentDataStoreImplFactoryImpl @Inject constructor() : VisualDesignExperimentDataStoreImplFactory {
133+
override fun create(
134+
appCoroutineScope: CoroutineScope,
135+
experimentalUIThemingFeature: ExperimentalUIThemingFeature,
136+
featureTogglesInventory: FeatureTogglesInventory,
137+
): VisualDesignExperimentDataStoreImpl {
138+
return VisualDesignExperimentDataStoreImpl(
139+
appCoroutineScope = appCoroutineScope,
140+
experimentalUIThemingFeature = experimentalUIThemingFeature,
141+
featureTogglesInventory = featureTogglesInventory,
142+
)
143+
}
144+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.common.ui.experiments.visual.store
18+
19+
import com.duckduckgo.app.di.AppCoroutineScope
20+
import com.duckduckgo.common.ui.experiments.visual.ExperimentalUIThemingFeature
21+
import com.duckduckgo.common.utils.DispatcherProvider
22+
import com.duckduckgo.di.scopes.AppScope
23+
import com.duckduckgo.feature.toggles.api.FeatureTogglesInventory
24+
import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin
25+
import com.squareup.anvil.annotations.ContributesBinding
26+
import com.squareup.anvil.annotations.ContributesMultibinding
27+
import dagger.SingleInstanceIn
28+
import javax.inject.Inject
29+
import kotlinx.coroutines.CoroutineScope
30+
import kotlinx.coroutines.flow.StateFlow
31+
import kotlinx.coroutines.runBlocking
32+
import kotlinx.coroutines.sync.Mutex
33+
import kotlinx.coroutines.sync.withLock
34+
import kotlinx.coroutines.withContext
35+
36+
/**
37+
* Original reason for creating this lazy provider is in https://app.asana.com/1/137249556945/task/1210355090527279/comment/1210422281011749?focus=true.
38+
*
39+
* Provides [VisualDesignExperimentDataStoreImpl] with an option to initialize it on a background thread
40+
* to avoid blocking the main thread during app startup.
41+
*
42+
* The data store performs synchronous checks of feature flags and experiments during initialization
43+
* to correctly set up its state flows. This is essential, as the state is used to determine the app’s main
44+
* theme and must be ready before any themeable activity launches to prevent flickering or incorrect
45+
* theming.
46+
*
47+
* Previously, the first accessor (e.g., Dagger initializer or theme manager) would block the main
48+
* thread while the store initialized. Offloading this work to a worker thread eliminates that risk.
49+
*
50+
* This provider is used in [com.duckduckgo.app.launch.LaunchBridgeActivity] to trigger initialization
51+
* while the splash screen is shown, ensuring the store is ready for synchronous access afterward.
52+
*
53+
* Access before the splash screen ends is not expected. A debug-only assertion guards
54+
* against premature access, allowing issues to be caught during development without impacting release builds
55+
* (in case there are flows we've not considered or tested yet but can happen in production).
56+
*/
57+
@ContributesBinding(
58+
scope = AppScope::class,
59+
boundType = VisualDesignExperimentDataStore::class,
60+
)
61+
@ContributesBinding(
62+
scope = AppScope::class,
63+
boundType = VisualDesignExperimentDataStoreInitializer::class,
64+
)
65+
@ContributesMultibinding(
66+
scope = AppScope::class,
67+
boundType = PrivacyConfigCallbackPlugin::class,
68+
)
69+
@SingleInstanceIn(scope = AppScope::class)
70+
class VisualDesignExperimentDataStoreLazyProvider @Inject constructor(
71+
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
72+
private val experimentalUIThemingFeature: ExperimentalUIThemingFeature,
73+
private val featureTogglesInventory: FeatureTogglesInventory,
74+
private val dispatcherProvider: DispatcherProvider,
75+
private val visualDesignExperimentDataStoreImplFactory: VisualDesignExperimentDataStoreImplFactory,
76+
) : VisualDesignExperimentDataStoreInitializer, VisualDesignExperimentDataStore, PrivacyConfigCallbackPlugin {
77+
78+
private val initMutex = Mutex()
79+
80+
private var _store: VisualDesignExperimentDataStoreImpl? = null
81+
82+
private val store: VisualDesignExperimentDataStore
83+
get() {
84+
val ref = _store
85+
assert(ref != null) { "VisualDesignExperimentDataStore is not initialized." }
86+
return ref ?: runBlocking { initialize() }
87+
}
88+
89+
override suspend fun initialize(): VisualDesignExperimentDataStore = initMutex.withLock {
90+
suspend fun createStore(): VisualDesignExperimentDataStoreImpl = withContext(dispatcherProvider.io()) {
91+
visualDesignExperimentDataStoreImplFactory.create(
92+
appCoroutineScope = appCoroutineScope,
93+
experimentalUIThemingFeature = experimentalUIThemingFeature,
94+
featureTogglesInventory = featureTogglesInventory,
95+
)
96+
}
97+
98+
_store ?: createStore().also { _store = it }
99+
}
100+
101+
override fun onPrivacyConfigDownloaded() {
102+
// store fetches latest flags on initialization, so updates are only needed if the store has already been initialized before
103+
_store?.onPrivacyConfigDownloaded()
104+
}
105+
106+
override val isExperimentEnabled: StateFlow<Boolean>
107+
get() = store.isExperimentEnabled
108+
override val isDuckAIPoCEnabled: StateFlow<Boolean>
109+
get() = store.isDuckAIPoCEnabled
110+
override val anyConflictingExperimentEnabled: StateFlow<Boolean>
111+
get() = store.anyConflictingExperimentEnabled
112+
override fun changeExperimentFlagPreference(enabled: Boolean) = store.changeExperimentFlagPreference(enabled)
113+
override fun changeDuckAIPoCFlagPreference(enabled: Boolean) = store.changeDuckAIPoCFlagPreference(enabled)
114+
}

0 commit comments

Comments
 (0)