diff --git a/.maestro/shared/skip_all_onboarding.yaml b/.maestro/shared/skip_all_onboarding.yaml new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/app-build-config/app-build-config-api/src/main/java/com/duckduckgo/appbuildconfig/api/AppBuildConfig.kt b/app-build-config/app-build-config-api/src/main/java/com/duckduckgo/appbuildconfig/api/AppBuildConfig.kt index b7ac9272df66..4424fd476828 100644 --- a/app-build-config/app-build-config-api/src/main/java/com/duckduckgo/appbuildconfig/api/AppBuildConfig.kt +++ b/app-build-config/app-build-config-api/src/main/java/com/duckduckgo/appbuildconfig/api/AppBuildConfig.kt @@ -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 diff --git a/app/build.gradle b/app/build.gradle index aeb4054e6784..187c85ab93e5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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 { diff --git a/app/src/main/java/com/duckduckgo/app/buildconfig/RealAppBuildConfig.kt b/app/src/main/java/com/duckduckgo/app/buildconfig/RealAppBuildConfig.kt index d588b63c6858..f6011ceb2227 100644 --- a/app/src/main/java/com/duckduckgo/app/buildconfig/RealAppBuildConfig.kt +++ b/app/src/main/java/com/duckduckgo/app/buildconfig/RealAppBuildConfig.kt @@ -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 @@ -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()) { diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/FullOnboardingSkipper.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/FullOnboardingSkipper.kt new file mode 100644 index 000000000000..7b0119bd4c1b --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/FullOnboardingSkipper.kt @@ -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 +} + +@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, + ) +} diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingActivity.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingActivity.kt index 43786cbf0547..74098a21c78e 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingActivity.kt @@ -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() { @@ -42,6 +50,8 @@ class OnboardingActivity : DuckDuckGoActivity() { super.onCreate(savedInstanceState) setContentView(binding.root) configurePager() + configureSkipButton() + observeViewModel() } fun onContinueClicked() { @@ -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 { diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingViewModel.kt b/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingViewModel.kt index 89d9dc0e83ea..921a19a6ddcb 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/ui/OnboardingViewModel.kt @@ -22,9 +22,12 @@ 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) @@ -32,8 +35,13 @@ 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() } @@ -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) + } } diff --git a/app/src/main/res/layout/activity_onboarding.xml b/app/src/main/res/layout/activity_onboarding.xml index fc47e1bbe92d..4b8ff72c9515 100644 --- a/app/src/main/res/layout/activity_onboarding.xml +++ b/app/src/main/res/layout/activity_onboarding.xml @@ -41,4 +41,16 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> + + diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 004f783907ee..9bcf3c950b36 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -76,4 +76,7 @@ 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. Okay + + Skip Onboarding" + diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/FullOnboardingSkipperTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/FullOnboardingSkipperTest.kt new file mode 100644 index 000000000000..897cd7814ce1 --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/FullOnboardingSkipperTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/duckduckgo/app/onboarding/ui/OnboardingViewModelTest.kt b/app/src/test/java/com/duckduckgo/app/onboarding/ui/OnboardingViewModelTest.kt index 263e655c3fb7..fea3c230dcce 100644 --- a/app/src/test/java/com/duckduckgo/app/onboarding/ui/OnboardingViewModelTest.kt +++ b/app/src/test/java/com/duckduckgo/app/onboarding/ui/OnboardingViewModelTest.kt @@ -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 { @@ -40,8 +45,18 @@ 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 @@ -49,4 +64,33 @@ class OnboardingViewModelTest { 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() + 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) + } }