Skip to content

Commit 2d81e97

Browse files
authored
Custom Duck.ai Onboarding - update bubble CTA copy and subscription page for custom AI onboarding (#8706)
Task/Issue URL: https://app.asana.com/1/137249556945/project/1208671518894266/task/1214496769534854?focus=true ### Description Adds alternative copy and link variants for onboarding bubble CTAs when custom AI onboarding flow is engaged. ### Steps to test this PR - [x] Apply this diff: ```diff diff --git a/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt b/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt index f3fdf90..485e6704af 100644 --- a/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt @@ -113,7 +113,7 @@ class CtaViewModel @Inject constructor( } private suspend fun isSubscriptionCtaAvailable(): Boolean = - subscriptions.isEligible() && hasNoSubscription() && extendedOnboardingFeatureToggles.privacyProCta().isEnabled() + true//subscriptions.isEligible() && hasNoSubscription() && extendedOnboardingFeatureToggles.privacyProCta().isEnabled() private suspend fun isBrandDesignUpdateEnabled(): Boolean = withContext(dispatchers.io()) { onboardingBrandDesignUpdateToggles.brandDesignUpdate().isEnabled() ``` - [x] Clean install the app. - [x] Go through onboarding until reaching the End dialog. - [x] Verify the copy matches the existing production variant. - [x] Click the primary button. - [x] Verify the copy on the Subscription dialog matches the existing production variant. - [x] Click the primary button. - [x] Verify the default subscription page loads. - [x] Apply this patch on top of the last: ```diff diff --git a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt index 19f4e74..ad958e68ce 100644 --- a/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt +++ b/app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt @@ -222,7 +222,7 @@ class OnboardingStoreImpl @Inject constructor( } override fun isCustomAiOnboardingFlow(): Boolean { - return false + return true } ``` - [x] Clean install the app. - [x] Go through onboarding until reaching the End dialog. - [x] Verify the copy matches the alternative variant. - [x] Click the primary button. - [x] Verify the copy on the Subscription dialog matches the alternative variant. - [x] Click the primary button. - [x] Verify the alternative, Duck.ai-focused subscription page loads. ### UI changes | Default | Custom AI flow | | ------ | ----- | |<img width="480" height="1071" alt="image" src="https://github.com/user-attachments/assets/5fc74d9f-0169-4df6-a423-32a0ad9de13e" /><img width="480" height="1071" alt="image" src="https://github.com/user-attachments/assets/a4940412-c55b-4620-816c-6353c896c444" /><img width="480" height="1071" alt="image" src="https://github.com/user-attachments/assets/b93e702c-4e86-44e8-9e76-c8c8d841074f" />|<img width="480" height="1071" alt="image" src="https://github.com/user-attachments/assets/e70c374a-50f3-4ebe-bd46-faa966268171" /><img width="480" height="1071" alt="image" src="https://github.com/user-attachments/assets/0b8efd44-5598-489c-adf7-3fb7b1949466" /><img width="480" height="1071" alt="image" src="https://github.com/user-attachments/assets/4581be73-74c9-42fe-9728-c3f8c0aaaff3" />| <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Onboarding UI and subscription URL query params only; flow is gated behind a stub that returns false until referral wiring ships. > > **Overview** > Introduces a **custom AI onboarding** branch (via `OnboardingStore.isCustomAiOnboardingFlow()`, still stubbed to `false` until referral integration lands) that swaps Dax **end** and **Privacy Pro subscription** bubble copy and tweaks presentation for that path. > > When the flag is on, the end bubble uses new strings and can **inline a chat icon** in the description through a new `decorateDescription` hook on brand-design bubble CTAs. Tapping subscription from onboarding still opens `duckduckgo.com/pro` with `origin=funnel_onboarding_android`, and additionally appends **`featurePage=duckai`** so the web subscription experience can land on Duck.ai-focused content. > > Unit tests cover the subscription URI with and without the custom-AI query parameter. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 75a6478. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 812b029 commit 2d81e97

8 files changed

Lines changed: 72 additions & 9 deletions

File tree

app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ import com.duckduckgo.app.global.model.domain
275275
import com.duckduckgo.app.global.model.domainMatchesUrl
276276
import com.duckduckgo.app.global.model.orderedTrackerBlockedEntities
277277
import com.duckduckgo.app.location.data.LocationPermissionType
278+
import com.duckduckgo.app.onboarding.store.OnboardingStore
278279
import com.duckduckgo.app.onboardingbranddesignupdate.OnboardingBrandDesignUpdateToggles
279280
import com.duckduckgo.app.pixels.AppPixelName
280281
import com.duckduckgo.app.pixels.AppPixelName.AUTOCOMPLETE_RESULT_DELETED
@@ -557,6 +558,7 @@ class BrowserTabViewModel @Inject constructor(
557558
private val downloadMenuStateProvider: DownloadMenuStateProvider,
558559
private val downloadsRepository: DownloadsRepository,
559560
private val onboardingBrandDesignUpdateToggles: OnboardingBrandDesignUpdateToggles,
561+
private val onboardingStore: OnboardingStore,
560562
) : ViewModel(),
561563
WebViewClientListener,
562564
EditSavedSiteListener,
@@ -5071,8 +5073,15 @@ class BrowserTabViewModel @Inject constructor(
50715073
is DaxSubscriptionBrandDesignUpdateBubbleCta,
50725074
-> {
50735075
viewModelScope.launch {
5074-
val origin = "funnel_onboarding_android"
5075-
command.value = LaunchSubscription("https://duckduckgo.com/pro?origin=$origin".toUri())
5076+
val uri = "https://duckduckgo.com/pro".toUri().buildUpon()
5077+
.appendQueryParameter("origin", "funnel_onboarding_android")
5078+
.apply {
5079+
if (onboardingStore.isCustomAiOnboardingFlow()) {
5080+
appendQueryParameter("featurePage", "duckai")
5081+
}
5082+
}
5083+
.build()
5084+
command.value = LaunchSubscription(uri)
50765085
}
50775086
}
50785087
is DaxBubbleCta.DaxEndCta,

app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1620,6 +1620,8 @@ sealed class DaxBubbleCta(
16201620

16211621
abstract fun configureContentViews(view: View)
16221622

1623+
protected open fun decorateDescription(context: Context, text: CharSequence): CharSequence = text
1624+
16231625
private var cardContainer: TouchInterceptingLinearLayout? = null
16241626

16251627
private var isAnimating: Boolean = false
@@ -1701,6 +1703,7 @@ sealed class DaxBubbleCta(
17011703

17021704
val daxTitle = container.context.getString(title)
17031705
val daxDescription = container.context.getString(description).preventWidows()
1706+
val descriptionText = decorateDescription(container.context, daxDescription.html(container.context))
17041707

17051708
val titleView = container.findViewById<DaxTypeAnimationTextView>(R.id.brandDesignTitle)
17061709
val hiddenTitle = container.findViewById<DaxTextView>(R.id.brandDesignHiddenTitle)
@@ -1732,7 +1735,7 @@ sealed class DaxBubbleCta(
17321735
// Helper: type title then fade in content
17331736
val typeAndFadeIn = {
17341737
hiddenTitle.text = daxTitle.html(container.context)
1735-
descriptionView.text = daxDescription.html(container.context)
1738+
descriptionView.text = descriptionText
17361739

17371740
val startTyping = {
17381741
titleView.alpha = 1f
@@ -1793,7 +1796,7 @@ sealed class DaxBubbleCta(
17931796

17941797
val applySettledState = {
17951798
hiddenTitle.text = daxTitle.html(container.context)
1796-
descriptionView.text = daxDescription.html(container.context)
1799+
descriptionView.text = descriptionText
17971800
if (!titleView.hasAnimationStarted()) {
17981801
titleView.text = daxTitle.html(container.context)
17991802
}
@@ -1856,7 +1859,7 @@ sealed class DaxBubbleCta(
18561859
clearDialog()
18571860
resetAllIncludesExcept(container, activeInclude)
18581861
hiddenTitle.text = daxTitle.html(container.context)
1859-
descriptionView.text = daxDescription.html(container.context)
1862+
descriptionView.text = descriptionText
18601863
resetHeaderState()
18611864
resetTextAlignment()
18621865
configureContentViews(container)

app/src/main/java/com/duckduckgo/app/cta/ui/DaxEndBrandDesignUpdateBubbleCta.kt

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,18 @@
1616

1717
package com.duckduckgo.app.cta.ui
1818

19+
import android.content.Context
1920
import android.view.View
2021
import com.duckduckgo.app.browser.R
2122
import com.duckduckgo.app.cta.model.CtaId
22-
import com.duckduckgo.app.cta.ui.DaxBubbleCta.WavingDaxSpec
2323
import com.duckduckgo.app.global.install.AppInstallStore
2424
import com.duckduckgo.app.onboarding.store.OnboardingStore
2525
import com.duckduckgo.app.pixels.AppPixelName
2626
import com.duckduckgo.app.statistics.pixels.Pixel
27+
import com.duckduckgo.common.ui.view.appendIconToText
2728
import com.duckduckgo.common.utils.device.DeviceInfo
2829
import com.google.android.material.button.MaterialButton
30+
import com.duckduckgo.mobile.android.R as CommonR
2931

3032
data class DaxEndBrandDesignUpdateBubbleCta(
3133
override val onboardingStore: OnboardingStore,
@@ -35,7 +37,11 @@ data class DaxEndBrandDesignUpdateBubbleCta(
3537
) : DaxBubbleCta.BrandDesignUpdateBubbleCta(
3638
ctaId = CtaId.DAX_END,
3739
title = R.string.onboardingEndDaxDialogTitle,
38-
description = R.string.onboardingEndDaxDialogDescription,
40+
description = if (onboardingStore.isCustomAiOnboardingFlow()) {
41+
R.string.onboardingEndCustomAiFlowDaxDialogDescription
42+
} else {
43+
R.string.onboardingEndDaxDialogDescription
44+
},
3945
backgroundRes = R.drawable.bg_onboarding_end,
4046
shownPixel = AppPixelName.ONBOARDING_DAX_CTA_SHOWN,
4147
okPixel = AppPixelName.ONBOARDING_DAX_CTA_OK_BUTTON,
@@ -59,4 +65,11 @@ data class DaxEndBrandDesignUpdateBubbleCta(
5965
override fun configureContentViews(view: View) {
6066
view.findViewById<MaterialButton>(R.id.primaryCta)?.setText(R.string.onboardingEndDaxDialogButton)
6167
}
68+
69+
override fun decorateDescription(context: Context, text: CharSequence): CharSequence =
70+
if (onboardingStore.isCustomAiOnboardingFlow()) {
71+
context.appendIconToText(text, CommonR.drawable.ic_ai_chat_16)
72+
} else {
73+
text
74+
}
6275
}

app/src/main/java/com/duckduckgo/app/cta/ui/DaxSubscriptionBrandDesignUpdateBubbleCta.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,11 @@ data class DaxSubscriptionBrandDesignUpdateBubbleCta(
4040
) : DaxBubbleCta.BrandDesignUpdateBubbleCta(
4141
ctaId = CtaId.DAX_INTRO_PRIVACY_PRO,
4242
title = R.string.onboardingPrivacyProDaxDialogTitle,
43-
description = R.string.onboardingPrivacyProDaxDialogDescription,
43+
description = if (onboardingStore.isCustomAiOnboardingFlow()) {
44+
R.string.onboardingPrivacyProCustomAiFlowDaxDialogDescription
45+
} else {
46+
R.string.onboardingPrivacyProDaxDialogDescription
47+
},
4448
backgroundRes = R.drawable.bg_onboarding_subscription,
4549
shownPixel = AppPixelName.ONBOARDING_DAX_CTA_SHOWN,
4650
okPixel = AppPixelName.ONBOARDING_DAX_CTA_OK_BUTTON,

app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStore.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,11 @@ interface OnboardingStore {
3030
fun setInputScreenSelectionOverriddenByUser()
3131
fun setDuckAiOnboardingFlow()
3232
fun isDuckAiOnboardingFlow(): Boolean
33+
34+
/**
35+
* `true` if the user installs through AI referral link
36+
*
37+
* **Note: this feature is WIP, details to follow during the rest of integration**
38+
*/
39+
fun isCustomAiOnboardingFlow(): Boolean
3340
}

app/src/main/java/com/duckduckgo/app/onboarding/store/OnboardingStoreImpl.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,10 @@ class OnboardingStoreImpl @Inject constructor(
221221
return preferences.getBoolean(KEY_DUCK_AI_ONBOARDING_FLOW, false)
222222
}
223223

224+
override fun isCustomAiOnboardingFlow(): Boolean {
225+
return false
226+
}
227+
224228
companion object {
225229
const val FILENAME = "com.duckduckgo.app.onboarding.settings"
226230
const val ONBOARDING_JOURNEY = "onboardingJourney"

app/src/main/res/values/donottranslate.xml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,12 +132,17 @@
132132
<string name="browserModeToggleRegularDescription">Switch to regular tabs</string>
133133
<string name="browserModeToggleTabCountInfinite">∞</string>
134134

135-
<!-- Pre-onboarding: AI comparison chart screen. Move to strings.xml when localized. -->
135+
<!-- Custom AI onboarding flow -->
136+
<!-- Pre-onboarding: AI comparison chart screen. -->
136137
<string name="preOnboardingDaxDialogAiTitle">AI protections activated!</string>
137138
<string name="preOnboardingAiComparisonChartPopularAis">Popular AIs</string>
138139
<string name="preOnboardingAiComparisonChartItem1">All chats are anonymized</string>
139140
<string name="preOnboardingAiComparisonChartItem2">No account needed to access all AI features</string>
140141
<string name="preOnboardingAiComparisonChartItem3">Never uses your chats to train AI</string>
141142
<string name="preOnboardingAiComparisonChartItem4">Access ChatGPT, Claude, and more, all in one place.</string>
142143
<string name="preOnboardingAiComparisonChartButton">Give Duck.ai a try!</string>
144+
<!-- In-context onboarding. -->
145+
<string name="onboardingEndCustomAiFlowDaxDialogDescription"><![CDATA[Start a private AI chat with Duck.ai or toggle to Search for protected browsing.<br/><br/>You can use Duck.ai from anywhere you see the chat icon]]></string>
146+
<string name="onboardingPrivacyProCustomAiFlowDaxDialogDescription">We also offer a paid subscription featuring advanced AI models with higher chat limits from GPT, Claude, and Llama, a secure VPN, and more.</string>
147+
143148
</resources>

app/src/test/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -994,6 +994,7 @@ class BrowserTabViewModelTest {
994994
downloadMenuStateProvider = mockDownloadMenuStateProvider,
995995
downloadsRepository = mockDownloadsRepository,
996996
onboardingBrandDesignUpdateToggles = mockOnboardingBrandDesignUpdateToggles,
997+
onboardingStore = mockOnboardingStore,
997998
)
998999

9991000
testee.loadData("abc", null, false, false)
@@ -3534,6 +3535,23 @@ class BrowserTabViewModelTest {
35343535
testee.onUserClickCtaOkButton(cta)
35353536
assertCommandIssued<LaunchSubscription> {
35363537
assertEquals("funnel_onboarding_android", uri.getQueryParameter("origin"))
3538+
assertNull(uri.getQueryParameter("featurePage"))
3539+
}
3540+
}
3541+
3542+
@Test
3543+
fun whenUserClickedDaxSubscriptionCtaInCustomAiOnboardingFlowThenLaunchSubscriptionWithFeaturePageDuckAi() {
3544+
whenever(mockOnboardingStore.isCustomAiOnboardingFlow()).thenReturn(true)
3545+
val cta = DaxBubbleCta.DaxSubscriptionCta(
3546+
mockOnboardingStore,
3547+
mockAppInstallStore,
3548+
isFreeTrialCopy = false,
3549+
)
3550+
setCta(cta)
3551+
testee.onUserClickCtaOkButton(cta)
3552+
assertCommandIssued<LaunchSubscription> {
3553+
assertEquals("funnel_onboarding_android", uri.getQueryParameter("origin"))
3554+
assertEquals("duckai", uri.getQueryParameter("featurePage"))
35373555
}
35383556
}
35393557

0 commit comments

Comments
 (0)