Skip to content

Commit

Permalink
Support skipping onboarding with gradle build flag
Browse files Browse the repository at this point in the history
  • Loading branch information
CDRussell committed Feb 24, 2025
1 parent 6e0de0c commit fa1a69f
Show file tree
Hide file tree
Showing 11 changed files with 272 additions and 1 deletion.
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ interface AppBuildConfig {
val deviceLocale: Locale
val isDefaultVariantForced: Boolean
val buildDateTimeMillis: Long
val canSkipOnboarding: Boolean

/**
* You should call [variantName] in a background thread
Expand Down
5 changes: 5 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ android {
} else {
buildConfigField "boolean", "FORCE_DEFAULT_VARIANT", "false"
}
if (project.hasProperty('skip-onboarding')) {
buildConfigField "boolean", "CAN_SKIP_ONBOARDING", "true"
} else {
buildConfigField "boolean", "CAN_SKIP_ONBOARDING", "false"
}
if (project.hasProperty('build-date-time')) {
buildConfigField "long", "BUILD_DATE_MILLIS", "${System.currentTimeMillis()}"
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import androidx.core.content.edit
import com.duckduckgo.app.browser.BuildConfig
import com.duckduckgo.appbuildconfig.api.AppBuildConfig
import com.duckduckgo.appbuildconfig.api.BuildFlavor
import com.duckduckgo.appbuildconfig.api.isInternalBuild
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.data.store.api.SharedPreferencesProvider
import com.duckduckgo.di.scopes.AppScope
Expand Down Expand Up @@ -105,6 +106,9 @@ class RealAppBuildConfig @Inject constructor(
override val buildDateTimeMillis: Long
get() = BuildConfig.BUILD_DATE_MILLIS

override val canSkipOnboarding: Boolean
get() = BuildConfig.CAN_SKIP_ONBOARDING || isInternalBuild()

private fun getDownloadsDirectory(): File {
val downloadDirectory = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
if (!downloadDirectory.exists()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/*
* 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.ui

import com.duckduckgo.app.cta.db.DismissedCtaDao
import com.duckduckgo.app.cta.model.CtaId
import com.duckduckgo.app.cta.model.DismissedCta
import com.duckduckgo.app.onboarding.store.AppStage
import com.duckduckgo.app.onboarding.store.UserStageStore
import com.duckduckgo.app.onboarding.ui.FullOnboardingSkipper.ViewState
import com.duckduckgo.app.settings.db.SettingsDataStore
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.privacy.config.api.PrivacyConfigCallbackPlugin
import com.squareup.anvil.annotations.ContributesBinding
import com.squareup.anvil.annotations.ContributesMultibinding
import dagger.SingleInstanceIn
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext

interface OnboardingSkipper {
suspend fun markOnboardingAsCompleted()
val privacyConfigDownloaded: SharedFlow<ViewState>
}

@ContributesMultibinding(
scope = AppScope::class,
boundType = PrivacyConfigCallbackPlugin::class,
)
@ContributesBinding(AppScope::class, OnboardingSkipper::class)
@SingleInstanceIn(AppScope::class)
class FullOnboardingSkipper @Inject constructor(
private val dispatchers: DispatcherProvider,
private val settingsDataStore: SettingsDataStore,
private val dismissedCtaDao: DismissedCtaDao,
private val userStageStore: UserStageStore,
) : OnboardingSkipper, PrivacyConfigCallbackPlugin {

private val _privacyConfigDownloaded = MutableStateFlow(ViewState())
override val privacyConfigDownloaded = _privacyConfigDownloaded.asStateFlow()

@Suppress("DEPRECATION")
override suspend fun markOnboardingAsCompleted() {
withContext(dispatchers.io()) {
settingsDataStore.hideTips = true
dismissedCtaDao.insert(DismissedCta(CtaId.ADD_WIDGET))
userStageStore.stageCompleted(AppStage.DAX_ONBOARDING)
}
}

override fun onPrivacyConfigDownloaded() {
_privacyConfigDownloaded.value = ViewState(skipOnboardingPossible = true)
}

data class ViewState(
val skipOnboardingPossible: Boolean = false,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,20 @@ package com.duckduckgo.app.onboarding.ui
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.lifecycle.Lifecycle.State.STARTED
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.duckduckgo.anvil.annotations.InjectWith
import com.duckduckgo.app.browser.BrowserActivity
import com.duckduckgo.app.browser.databinding.ActivityOnboardingBinding
import com.duckduckgo.common.ui.DuckDuckGoActivity
import com.duckduckgo.common.ui.view.gone
import com.duckduckgo.common.ui.view.show
import com.duckduckgo.common.ui.viewbinding.viewBinding
import com.duckduckgo.di.scopes.ActivityScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch

@InjectWith(ActivityScope::class)
class OnboardingActivity : DuckDuckGoActivity() {
Expand All @@ -42,6 +50,8 @@ class OnboardingActivity : DuckDuckGoActivity() {
super.onCreate(savedInstanceState)
setContentView(binding.root)
configurePager()
configureSkipButton()
observeViewModel()
}

fun onContinueClicked() {
Expand Down Expand Up @@ -78,6 +88,29 @@ class OnboardingActivity : DuckDuckGoActivity() {
}
}

private fun observeViewModel() {
viewModel.viewState.flowWithLifecycle(lifecycle, STARTED)
.onEach {
if (it.canShowSkipOnboardingButton) {
binding.skipOnboardingButton.show()
} else {
binding.skipOnboardingButton.gone()
}
}
.launchIn(lifecycleScope)
}

private fun configureSkipButton() {
binding.skipOnboardingButton.setOnClickListener {
lifecycleScope.launch {
viewModel.devOnlyFullyCompleteAllOnboarding()
startActivity(BrowserActivity.intent(this@OnboardingActivity))
finish()
}
}
viewModel.initializeOnboardingSkipper()
}

companion object {

fun intent(context: Context): Intent {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,26 @@ import com.duckduckgo.anvil.annotations.ContributesViewModel
import com.duckduckgo.app.onboarding.store.AppStage
import com.duckduckgo.app.onboarding.store.UserStageStore
import com.duckduckgo.app.onboarding.ui.page.OnboardingPageFragment
import com.duckduckgo.appbuildconfig.api.AppBuildConfig
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.ActivityScope
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

@ContributesViewModel(ActivityScope::class)
class OnboardingViewModel @Inject constructor(
private val userStageStore: UserStageStore,
private val pageLayoutManager: OnboardingPageManager,
private val dispatchers: DispatcherProvider,
private val onboardingSkipper: OnboardingSkipper,
private val appBuildConfig: AppBuildConfig,
) : ViewModel() {

private val _viewState = MutableStateFlow(ViewState())
val viewState = _viewState.asStateFlow()

fun initializePages() {
pageLayoutManager.buildPageBlueprints()
}
Expand All @@ -52,4 +60,23 @@ class OnboardingViewModel @Inject constructor(
userStageStore.stageCompleted(AppStage.NEW)
}
}

fun initializeOnboardingSkipper() {
if (!appBuildConfig.canSkipOnboarding) return

// delay showing skip button until privacy config downloaded
viewModelScope.launch {
onboardingSkipper.privacyConfigDownloaded.collect {
_viewState.value = _viewState.value.copy(canShowSkipOnboardingButton = it.skipOnboardingPossible)
}
}
}

suspend fun devOnlyFullyCompleteAllOnboarding() {
onboardingSkipper.markOnboardingAsCompleted()
}

companion object {
data class ViewState(val canShowSkipOnboardingButton: Boolean = false)
}
}
12 changes: 12 additions & 0 deletions app/src/main/res/layout/activity_onboarding.xml
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,16 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<com.duckduckgo.common.ui.view.button.DaxButtonPrimary
android:id="@+id/skipOnboardingButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
android:layout_margin="30dp"
app:buttonSize="large"
app:layout_constraintTop_toTopOf="parent"
android:text="@string/skipOnboarding"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
3 changes: 3 additions & 0 deletions app/src/main/res/values/donottranslate.xml
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,7 @@
<string name="newTabPageIndonesiaMessageBody">The government may be blocking access to duckduckgo.com on this network provider, which could affect this app\'s functionality. Other providers may not be affected.</string>
<string name="newTabPageIndonesiaMessageCta">Okay</string>

<!-- Skip Onboarding (not user-facing) -->
<string name="skipOnboarding">Skip Onboarding</string>"

</resources>
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.duckduckgo.app.onboarding.ui

import app.cash.turbine.test
import com.duckduckgo.app.cta.db.DismissedCtaDao
import com.duckduckgo.app.cta.model.CtaId.ADD_WIDGET
import com.duckduckgo.app.cta.model.DismissedCta
import com.duckduckgo.app.onboarding.store.AppStage.DAX_ONBOARDING
import com.duckduckgo.app.onboarding.store.UserStageStore
import com.duckduckgo.app.settings.db.SettingsDataStore
import com.duckduckgo.common.test.CoroutineTestRule
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.times
import org.mockito.kotlin.verify

@Suppress("DEPRECATION")
class FullOnboardingSkipperTest {

@get:Rule
val coroutineTestRule: CoroutineTestRule = CoroutineTestRule()

private val dismissedCtaDao: DismissedCtaDao = mock()
private val userStageStore: UserStageStore = mock()
private val settingsDataStore: SettingsDataStore = mock()

private val testee = FullOnboardingSkipper(
dispatchers = coroutineTestRule.testDispatcherProvider,
settingsDataStore = settingsDataStore,
dismissedCtaDao = dismissedCtaDao,
userStageStore = userStageStore,
)

@Test
fun whenPrivacyConfigNotYetDownloadedThenCannotSkipOnboarding() = runTest {
// checking value before privacy config downloaded
testee.privacyConfigDownloaded.test {
assertFalse(awaitItem().skipOnboardingPossible)
}
}

@Test
fun whenPrivacyConfigDownloadedThenViewStateUpdated() = runTest {
testee.onPrivacyConfigDownloaded()
testee.privacyConfigDownloaded.test {
assertTrue(awaitItem().skipOnboardingPossible)
}
}

@Test
fun whenSkipperInvokedToMarkOnboardingDoneThenAllOnboardingStoresUpdated() = runTest {
testee.markOnboardingAsCompleted()
verifyStoreInvocations(expectingToBeCalled = true)
}

private suspend fun verifyStoreInvocations(expectingToBeCalled: Boolean) {
val verificationMode = if (expectingToBeCalled) times(1) else never()

verify(settingsDataStore, verificationMode).hideTips = true
verify(dismissedCtaDao, verificationMode).insert(DismissedCta(ADD_WIDGET))
verify(userStageStore, verificationMode).stageCompleted(DAX_ONBOARDING)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,17 @@ package com.duckduckgo.app.onboarding.ui
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.duckduckgo.app.onboarding.store.AppStage
import com.duckduckgo.app.onboarding.store.UserStageStore
import com.duckduckgo.app.onboarding.ui.FullOnboardingSkipper.ViewState
import com.duckduckgo.appbuildconfig.api.AppBuildConfig
import com.duckduckgo.common.test.CoroutineTestRule
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.verifyNoInteractions
import org.mockito.kotlin.whenever

@Suppress("EXPERIMENTAL_API_USAGE")
class OnboardingViewModelTest {
Expand All @@ -40,13 +45,52 @@ class OnboardingViewModelTest {

private val pageLayout: OnboardingPageManager = mock()

private val onboardingSkipper: OnboardingSkipper = mock()

private val appBuildConfig: AppBuildConfig = mock()

private val testee: OnboardingViewModel by lazy {
OnboardingViewModel(userStageStore, pageLayout, coroutineRule.testDispatcherProvider)
OnboardingViewModel(
userStageStore = userStageStore,
pageLayoutManager = pageLayout,
dispatchers = coroutineRule.testDispatcherProvider,
onboardingSkipper = onboardingSkipper,
appBuildConfig = appBuildConfig,
)
}

@Test
fun whenOnboardingDoneThenCompleteStage() = runTest {
testee.onOnboardingDone()
verify(userStageStore).stageCompleted(AppStage.NEW)
}

@Test
fun whenAppBuildConfigPreventsSkippingOnboardingThenOnboardingSkipperNotInteractedWith() = runTest {
configureAppBuildConfigPreventsSkipping()
testee.initializeOnboardingSkipper()
verifyNoInteractions(onboardingSkipper)
}

@Test
fun whenAppBuildConfigAllowsSkippingOnboardingAndPrivacyConfigDownloadedSkippingIsPossible() = runTest {
configureAppBuildConfigAllowsSkipping()
configureSkipperFlow()
testee.initializeOnboardingSkipper()
verify(onboardingSkipper).privacyConfigDownloaded
}

private fun configureSkipperFlow() = runTest {
val flow = MutableSharedFlow<ViewState>()
flow.emit(ViewState(skipOnboardingPossible = true))
whenever(onboardingSkipper.privacyConfigDownloaded).thenReturn(flow)
}

private fun configureAppBuildConfigAllowsSkipping() {
whenever(appBuildConfig.canSkipOnboarding).thenReturn(true)
}

private fun configureAppBuildConfigPreventsSkipping() {
whenever(appBuildConfig.canSkipOnboarding).thenReturn(false)
}
}

0 comments on commit fa1a69f

Please sign in to comment.