Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a1c4873
Add CustomDuckAiOnboardingFeature toggle
LukasPaczos May 18, 2026
0ff8eee
Add Duck.ai welcome intro Lottie asset
LukasPaczos May 18, 2026
1c3c6ee
Add onboardingWelcomeIntroWith string
LukasPaczos May 18, 2026
5784d44
Add duckAiIntroAnimation view to welcome layout (portrait)
LukasPaczos May 18, 2026
a2aeb7f
Add duckAiIntroAnimation view to welcome layout (sw600dp)
LukasPaczos May 18, 2026
ade9d50
Add duckAiIntroAnimation view to welcome layout (landscape)
LukasPaczos May 18, 2026
80c3025
Resolve Duck.ai intro animation flag in welcome ViewModel
LukasPaczos May 18, 2026
2384514
Fix import order
LukasPaczos May 18, 2026
f5d8e87
Play with-Duck.ai intro animation on welcome page
LukasPaczos May 18, 2026
2b789d1
Track duckAi intro delayed runnable and cancel on destroy
LukasPaczos May 18, 2026
2392907
Register Lottie font delegate unconditionally to avoid font asset crash
LukasPaczos May 19, 2026
fb3bf51
Gate Lottie font delegate setup behind the intro animation flag
LukasPaczos May 19, 2026
3763f7a
attempt to fix timing and position
LukasPaczos May 19, 2026
b947e5a
Crop Duck.ai intro Lottie canvas and switch to JSON asset
LukasPaczos May 19, 2026
475b2fc
Unify Duck.ai intro view across layouts and theme its text color
LukasPaczos May 19, 2026
7255bdd
Extend Duck.ai intro canvas height so slide-in starts off-edge
LukasPaczos May 19, 2026
d3343af
Bump Duck.ai intro canvas height for a softer slide-in
LukasPaczos May 19, 2026
54473a0
Size Duck.ai intro view in sp so text renders at 24sp
LukasPaczos May 19, 2026
f8fff11
Remove constrainedWidth so Duck.ai intro view scales with font size
LukasPaczos May 19, 2026
2fa7b93
Compute Duck.ai intro view height via TypedValue so it honors non-lin…
LukasPaczos May 19, 2026
2b08444
clean up and remove test setup
LukasPaczos May 19, 2026
4b27771
address review comments
LukasPaczos May 19, 2026
8ec97bc
Use a single text layer for the Duck.ai intro to avoid translation gaps
LukasPaczos May 19, 2026
c57199a
Switch Duck.ai intro to language-agnostic '+ Duck.ai' Lottie
LukasPaczos May 19, 2026
8a15d13
Don't trigger Duck.ai intro from a cancelled intro animator
LukasPaczos May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2026 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

import com.duckduckgo.anvil.annotations.ContributesRemoteFeature
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.feature.toggles.api.Toggle
import com.duckduckgo.feature.toggles.api.Toggle.DefaultFeatureValue

@ContributesRemoteFeature(
scope = AppScope::class,
featureName = "customDuckAiOnboarding",
)
interface CustomDuckAiOnboardingFeature {

@Toggle.DefaultValue(DefaultFeatureValue.FALSE)
fun self(): Toggle

@Toggle.DefaultValue(DefaultFeatureValue.FALSE)
fun introAnimation(): Toggle
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import com.duckduckgo.app.browser.omnibar.OmnibarType
import com.duckduckgo.app.cta.ui.DaxBubbleCta.DaxDialogIntroOption
import com.duckduckgo.app.global.DefaultRoleBrowserDialog
import com.duckduckgo.app.global.install.AppInstallStore
import com.duckduckgo.app.onboarding.CustomDuckAiOnboardingFeature
import com.duckduckgo.app.onboarding.DuckAiOnboardingExperimentManager
import com.duckduckgo.app.onboarding.DuckAiOnboardingExperimentManager.DuckAiOnboardingExperimentVariant.CONTROL
import com.duckduckgo.app.onboarding.DuckAiOnboardingExperimentManager.DuckAiOnboardingExperimentVariant.TREATMENT_WITH_DUCK_AI_DEFAULT
Expand Down Expand Up @@ -96,6 +97,7 @@ class BrandDesignUpdatePageViewModel @Inject constructor(
private val inputScreenOnboardingWideEvent: InputScreenOnboardingWideEvent,
private val duckAiOnboardingExperimentManager: DuckAiOnboardingExperimentManager,
private val onboardingQuickSetupExperimentManager: OnboardingQuickSetupExperimentManager,
private val customDuckAiOnboardingFeature: CustomDuckAiOnboardingFeature,
) : ViewModel() {

data class ViewState(
Expand All @@ -109,6 +111,7 @@ class BrandDesignUpdatePageViewModel @Inject constructor(
val inputScreenPreviewSearchSuggestions: List<DaxDialogIntroOption> = emptyList(),
val inputScreenPreviewChatSuggestions: List<DaxDialogIntroOption> = emptyList(),
val inputScreenPreviewIsSearchSelected: Boolean = false,
val isDuckAiIntroAnimationEnabled: Boolean? = null,
)

private val _viewState = MutableStateFlow(ViewState())
Expand All @@ -126,6 +129,8 @@ class BrandDesignUpdatePageViewModel @Inject constructor(
} else {
2
}
val introAnimationEnabled = customDuckAiOnboardingFeature.introAnimation().isEnabled()
_viewState.update { it.copy(isDuckAiIntroAnimationEnabled = introAnimationEnabled) }
Comment thread
cursor[bot] marked this conversation as resolved.
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ import android.animation.ValueAnimator
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.graphics.Typeface
import android.graphics.drawable.AnimatedVectorDrawable
import android.os.Bundle
import android.util.TypedValue
import android.view.ContextThemeWrapper
import android.view.LayoutInflater
import android.view.View
Expand All @@ -37,6 +39,8 @@ import android.widget.ImageView
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.ViewCompat
import androidx.core.view.ViewGroupCompat
import androidx.core.view.WindowInsetsCompat
Expand All @@ -52,7 +56,10 @@ import androidx.lifecycle.lifecycleScope
import androidx.transition.ChangeBounds
import androidx.transition.TransitionListenerAdapter
import androidx.transition.TransitionManager
import com.airbnb.lottie.FontAssetDelegate
import com.airbnb.lottie.LottieAnimationView
import com.airbnb.lottie.LottieProperty
import com.airbnb.lottie.model.KeyPath
import com.duckduckgo.anvil.annotations.InjectWith
import com.duckduckgo.app.browser.R
import com.duckduckgo.app.browser.databinding.ContentOnboardingWelcomePageUpdateBinding
Expand Down Expand Up @@ -88,6 +95,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
import com.duckduckgo.fonts.R as FontsR
import com.duckduckgo.mobile.android.R as CommonR

@InjectWith(FragmentScope::class)
Expand Down Expand Up @@ -309,8 +317,17 @@ class BrandDesignUpdateWelcomePage : OnboardingPageFragment(R.layout.content_onb
interpolator = fadeEasing
}

val animators = mutableListOf<android.animation.Animator>(logoFade, textFade)
if (viewModel.viewState.value.isDuckAiIntroAnimationEnabled == true) {
val duckAiIntroFade = ObjectAnimator.ofFloat(binding.duckAiIntroAnimation, View.ALPHA, 1f, 0f).apply {
duration = OUTRO_FADE_DURATION
interpolator = fadeEasing
}
animators += duckAiIntroFade
}

return AnimatorSet().apply {
playTogether(logoFade, textFade)
playTogether(animators)
}
}

Expand Down Expand Up @@ -342,7 +359,7 @@ class BrandDesignUpdateWelcomePage : OnboardingPageFragment(R.layout.content_onb
}
}

private fun playIntroAnimation() {
private fun playIntroAnimation(isDuckAiIntroAnimationEnabled: Boolean) {
binding.backgroundPrimary.setMinFrame(BACKGROUND_MIN_FRAME)

backgroundIntroAnimatorSet = buildBackgroundIntroAnimatorSet()
Expand All @@ -359,16 +376,73 @@ class BrandDesignUpdateWelcomePage : OnboardingPageFragment(R.layout.content_onb
}
addAnimatorListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: android.animation.Animator) {
viewModel.onIntroAnimationFinished()
if (!isDuckAiIntroAnimationEnabled) {
viewModel.onIntroAnimationFinished()
}
}
})
playAnimation()
}
introAnimatorSet = buildIntroAnimatorSet().apply {
addListener(object : AnimatorListenerAdapter() {
private var cancelled = false

override fun onAnimationCancel(animation: Animator) {
cancelled = true
}

override fun onAnimationEnd(animation: Animator) {
if (cancelled || !isDuckAiIntroAnimationEnabled) return
prepareDuckAiIntroAnimation()
binding.duckAiIntroAnimation.isVisible = true
binding.duckAiIntroAnimation.addAnimatorListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
viewModel.onIntroAnimationFinished()
}
})
binding.duckAiIntroAnimation.playAnimation()
}
})
start()
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

private fun prepareDuckAiIntroAnimation() {
binding.duckAiIntroAnimation.apply {
// compute the view height so that it scales correctly with font size
val targetTextPx = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP,
DUCK_AI_INTRO_TEXT_SP,
resources.displayMetrics,
)
val viewHeightPx = (targetTextPx * DUCK_AI_INTRO_CANVAS_H / DUCK_AI_INTRO_TEXT_CANVAS_UNITS).toInt()
updateLayoutParams {
height = viewHeightPx
}

setFontAssetDelegate(object : FontAssetDelegate() {
override fun fetchFont(fontFamily: String): Typeface {
return ResourcesCompat.getFont(requireContext(), FontsR.font.ducksansdisplay_regular)
?: Typeface.DEFAULT
}
})

val textColor = resolveOnboardingTextPrimary(context)
addValueCallback(KeyPath("**", "Duck.ai"), LottieProperty.COLOR) { textColor }
addValueCallback(KeyPath("**", "+"), LottieProperty.COLOR) { textColor }
}
}

private fun resolveOnboardingTextPrimary(context: android.content.Context): Int {
val typedValue = TypedValue()
context.theme.resolveAttribute(CommonR.attr.onboardingTextPrimary, typedValue, true)
return if (typedValue.resourceId != 0) {
ContextCompat.getColor(context, typedValue.resourceId)
} else {
typedValue.data
}
}

private fun snapToIntroEndState() {
introAnimatorSet?.cancel()
backgroundIntroAnimatorSet?.cancel()
Expand Down Expand Up @@ -396,6 +470,13 @@ class BrandDesignUpdateWelcomePage : OnboardingPageFragment(R.layout.content_onb
setMinFrame(BACKGROUND_MIN_FRAME)
progress = 1f
}
if (viewModel.viewState.value.isDuckAiIntroAnimationEnabled == true) {
prepareDuckAiIntroAnimation()
with(binding.duckAiIntroAnimation) {
isVisible = true
progress = 1f
}
}
}

private fun playOutroAnimation(
Expand Down Expand Up @@ -463,7 +544,9 @@ class BrandDesignUpdateWelcomePage : OnboardingPageFragment(R.layout.content_onb
.flowWithLifecycle(viewLifecycleOwner.lifecycle, Lifecycle.State.STARTED)
.onEach { state ->
when {
!state.hasPlayedIntroAnimation -> binding.root.doOnLayout { playIntroAnimation() }
!state.hasPlayedIntroAnimation -> state.isDuckAiIntroAnimationEnabled?.let { enabled ->
binding.root.doOnLayout { playIntroAnimation(isDuckAiIntroAnimationEnabled = enabled) }
}
state.hasPlayedIntroAnimation && state.currentDialog == null -> snapToIntroEndState()
isAnimating -> { /* animation in progress — ignore re-emissions from onDialogAnimationStarted() */ }
state.hasAnimatedCurrentDialog -> {
Expand Down Expand Up @@ -591,6 +674,10 @@ class BrandDesignUpdateWelcomePage : OnboardingPageFragment(R.layout.content_onb
binding.welcomeScreenWalkingDax.cancelAnimation()
binding.bottomWingAnimation.cancelAnimation()
binding.leftWingAnimation.cancelAnimation()
binding.duckAiIntroAnimation.apply {
removeAllAnimatorListeners()
cancelAnimation()
}
}

override fun onActivityResult(
Expand Down Expand Up @@ -2182,6 +2269,12 @@ class BrandDesignUpdateWelcomePage : OnboardingPageFragment(R.layout.content_onb
private const val GUIDELINE_START_PERCENT = 0.5f
private const val GUIDELINE_END_PERCENT = 0.39125f

// Sizes the Duck.ai intro Lottie so its baked-in text renders at DUCK_AI_INTRO_TEXT_SP.
// Derived from the JSON's font-size scale chain: 24 * 3.4 * 0.78 * 1.085 ≈ 69 canvas units at end state.
private const val DUCK_AI_INTRO_TEXT_SP = 24f
private const val DUCK_AI_INTRO_CANVAS_H = 260f
private const val DUCK_AI_INTRO_TEXT_CANVAS_UNITS = 69f

private const val TEXT_INTRO_DELAY = 400L
private const val TEXT_INTRO_OPACITY_DURATION = 400L
private const val TEXT_INTRO_TRANSLATE_DURATION = 600L
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,23 @@
tools:alpha="1"
tools:ignore="DeprecatedWidgetInXml,InvalidColorAttribute" />

<com.airbnb.lottie.LottieAnimationView
android:id="@+id/duckAiIntroAnimation"
android:layout_width="wrap_content"
android:layout_height="90sp"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="12dp"
android:adjustViewBounds="true"
android:importantForAccessibility="no"
android:visibility="invisible"
app:layout_constraintTop_toBottomOf="@id/welcomeTitle"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:lottie_loop="false"
app:lottie_rawRes="@raw/onboarding_welcome_duck_ai_intro"
tools:lottie_progress="1"
tools:visibility="visible" />

<com.airbnb.lottie.LottieAnimationView
android:id="@+id/backgroundPrimary"
android:layout_width="0dp"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,22 @@
tools:alpha="1"
tools:ignore="DeprecatedWidgetInXml,InvalidColorAttribute" />

<com.airbnb.lottie.LottieAnimationView
android:id="@+id/duckAiIntroAnimation"
android:layout_width="wrap_content"
android:layout_height="90sp"
android:layout_marginTop="12dp"
android:adjustViewBounds="true"
android:importantForAccessibility="no"
android:visibility="invisible"
app:layout_constraintTop_toBottomOf="@id/welcomeTitle"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:lottie_loop="false"
app:lottie_rawRes="@raw/onboarding_welcome_duck_ai_intro"
tools:lottie_progress="1"
tools:visibility="visible" />

<com.airbnb.lottie.LottieAnimationView
android:id="@+id/backgroundPrimary"
android:layout_width="0dp"
Expand Down
16 changes: 16 additions & 0 deletions app/src/main/res/layout/content_onboarding_welcome_page_update.xml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,22 @@
tools:alpha="1"
tools:ignore="DeprecatedWidgetInXml,InvalidColorAttribute" />

<com.airbnb.lottie.LottieAnimationView
android:id="@+id/duckAiIntroAnimation"
android:layout_width="wrap_content"
android:layout_height="90sp"
android:layout_marginTop="12dp"
android:adjustViewBounds="true"
android:importantForAccessibility="no"
android:visibility="invisible"
app:layout_constraintTop_toBottomOf="@id/welcomeTitle"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:lottie_loop="false"
app:lottie_rawRes="@raw/onboarding_welcome_duck_ai_intro"
tools:lottie_progress="1"
tools:visibility="visible" />

<com.airbnb.lottie.LottieAnimationView
android:id="@+id/backgroundPrimary"
android:layout_width="0dp"
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/raw/onboarding_welcome_duck_ai_intro.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import com.duckduckgo.app.browser.omnibar.OmnibarType
import com.duckduckgo.app.cta.ui.DaxBubbleCta.DaxDialogIntroOption
import com.duckduckgo.app.global.DefaultRoleBrowserDialog
import com.duckduckgo.app.global.install.AppInstallStore
import com.duckduckgo.app.onboarding.CustomDuckAiOnboardingFeature
import com.duckduckgo.app.onboarding.DuckAiOnboardingExperimentManager
import com.duckduckgo.app.onboarding.DuckAiOnboardingExperimentManager.DuckAiOnboardingExperimentVariant
import com.duckduckgo.app.onboarding.store.OnboardingStore
Expand Down Expand Up @@ -86,6 +87,9 @@ class BrandDesignUpdatePageViewModelTest {
private val mockAndroidBrowserConfigFeature: AndroidBrowserConfigFeature = FakeFeatureToggleFactory.create(
AndroidBrowserConfigFeature::class.java,
)
private val mockCustomDuckAiOnboardingFeature: CustomDuckAiOnboardingFeature = FakeFeatureToggleFactory.create(
CustomDuckAiOnboardingFeature::class.java,
)
private val mockDuckChat: DuckChat = mock()
private val mockInputScreenOnboardingWideEvent: InputScreenOnboardingWideEvent = mock()
private val mockDuckAiOnboardingExperimentManager: DuckAiOnboardingExperimentManager = mock()
Expand All @@ -106,6 +110,7 @@ class BrandDesignUpdatePageViewModelTest {
mockInputScreenOnboardingWideEvent,
mockDuckAiOnboardingExperimentManager,
mockOnboardingQuickSetupExperimentManager,
mockCustomDuckAiOnboardingFeature,
)
}

Expand Down Expand Up @@ -154,6 +159,32 @@ class BrandDesignUpdatePageViewModelTest {
}
}

@Test
fun whenIntroAnimationFlagEnabledThenViewStateIsDuckAiIntroAnimationEnabledIsTrue() = runTest {
mockCustomDuckAiOnboardingFeature.introAnimation().setRawStoredState(Toggle.State(enable = true))

val testee = createViewModel()

testee.viewState.test {
val state = awaitItem()
assertEquals(true, state.isDuckAiIntroAnimationEnabled)
cancelAndConsumeRemainingEvents()
}
}

@Test
fun whenIntroAnimationFlagDisabledThenViewStateIsDuckAiIntroAnimationEnabledIsFalse() = runTest {
mockCustomDuckAiOnboardingFeature.introAnimation().setRawStoredState(Toggle.State(enable = false))

val testee = createViewModel()

testee.viewState.test {
val state = awaitItem()
assertEquals(false, state.isDuckAiIntroAnimationEnabled)
cancelAndConsumeRemainingEvents()
}
}

// endregion

// region loadDaxDialog
Expand Down
Loading