Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
Expand Up @@ -910,6 +910,8 @@ class WebViewRequestInterceptorTest {

override var searchRetentionAtb: String? = ""

override var duckaiRetentionAtb: String? = ""

override var variant: String? = ""

override var referrerVariant: String? = ""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,14 @@ class StatisticsRequesterJsonTest {
assertEquals("v99-1", statisticsStore.atb?.version)
}

@Test
fun whenAlreadyInitializedRefreshDuckAiRetentionCallWithUpdateVersionResponseUpdatesAtb() {
statisticsStore.saveAtb(Atb("100-1"))
queueResponseFromFile(VALID_UPDATE_RESPONSE_JSON)
testee.refreshDuckAiRetentionAtb()
assertEquals("v99-1", statisticsStore.atb?.version)
}

@Test
fun whenNotYetInitializedAtbInitializationStoresAtbResponse() {
queueResponseFromFile(VALID_JSON)
Expand Down Expand Up @@ -235,6 +243,16 @@ class StatisticsRequesterJsonTest {
assertEquals("app_use", refreshRequest?.extractQueryParam("at"))
}

@Test
fun whenAlreadyInitializedRefreshDuckAiCallGoesToCorrectEndpoint() {
statisticsStore.saveAtb(Atb("100-1"))
queueResponseFromFile(VALID_REFRESH_RESPONSE_JSON)
testee.refreshDuckAiRetentionAtb()
val refreshRequest = takeRequestImmediately()
assertEquals("/atb.js", refreshRequest?.encodedPath())
assertEquals("duckai", refreshRequest?.extractQueryParam("at"))
}

@Test
fun whenAlreadyInitializedRefreshSearchCallUpdatesSearchRetentionAtb() {
statisticsStore.saveAtb(Atb("100-1"))
Expand All @@ -251,6 +269,14 @@ class StatisticsRequesterJsonTest {
assertEquals("v107-7", statisticsStore.appRetentionAtb)
}

@Test
fun whenAlreadyInitializedRefreshDuckAiCallUpdatesAppRetentionAtb() {
statisticsStore.saveAtb(Atb("100-1"))
queueResponseFromFile(VALID_REFRESH_RESPONSE_JSON)
testee.refreshDuckAiRetentionAtb()
assertEquals("v107-7", statisticsStore.appRetentionAtb)
}

@Test
fun whenAlreadyInitializedRefreshSearchCallSendsTestParameter() {
statisticsStore.saveAtb(Atb("100-1"))
Expand All @@ -271,6 +297,16 @@ class StatisticsRequesterJsonTest {
assertTestParameterSent(testParam)
}

@Test
fun whenAlreadyInitializedRefreshDuckAiCallSendsTestParameter() {
statisticsStore.saveAtb(Atb("100-1"))
queueResponseFromFile(VALID_REFRESH_RESPONSE_JSON)
testee.refreshDuckAiRetentionAtb()
val refreshRequest = takeRequestImmediately()
val testParam = refreshRequest?.extractQueryParam(ParamKey.DEV_MODE)
assertTestParameterSent(testParam)
}

@Test
fun whenAlreadyInitializedRefreshSearchCallSendsCorrectAtb() {
statisticsStore.saveAtb(Atb("100-1"))
Expand All @@ -291,6 +327,16 @@ class StatisticsRequesterJsonTest {
assertEquals("100-1ma", atbParam)
}

@Test
fun whenAlreadyInitializedRefreshDuckAiCallSendsCorrectAtb() {
statisticsStore.saveAtb(Atb("100-1"))
queueResponseFromFile(VALID_REFRESH_RESPONSE_JSON)
testee.refreshDuckAiRetentionAtb()
val refreshRequest = takeRequestImmediately()
val atbParam = refreshRequest?.extractQueryParam(ParamKey.ATB)
assertEquals("100-1ma", atbParam)
}

@Test
fun whenAlreadyInitializedRefreshSearchCallSendsCorrectRetentionAtb() {
statisticsStore.saveAtb(Atb("100-1"))
Expand All @@ -313,6 +359,17 @@ class StatisticsRequesterJsonTest {
assertEquals("101-3", atbParam)
}

@Test
fun whenAlreadyInitializedRefreshDuckAiCallSendsCorrectRetentionAtb() {
statisticsStore.saveAtb(Atb("100-1"))
statisticsStore.appRetentionAtb = "101-3"
queueResponseFromFile(VALID_REFRESH_RESPONSE_JSON)
testee.refreshDuckAiRetentionAtb()
val refreshRequest = takeRequestImmediately()
val atbParam = refreshRequest?.extractQueryParam(ParamKey.RETENTION_ATB)
assertEquals("101-3", atbParam)
}

/**
* Should there be an issue obtaining the request, this will avoid the tests stalling indefinitely.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package com.duckduckgo.duckchat.impl.pixel

import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.statistics.api.StatisticsUpdater
import com.duckduckgo.app.statistics.pixels.Pixel
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin
Expand Down Expand Up @@ -120,16 +121,24 @@ class RealDuckChatPixels @Inject constructor(
private val duckChatFeatureRepository: DuckChatFeatureRepository,
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
private val statisticsUpdater: StatisticsUpdater,
) : DuckChatPixels {

override fun sendReportMetricPixel(reportMetric: ReportMetric) {
appCoroutineScope.launch(dispatcherProvider.io()) {
var refreshAtb = false
val sessionParams = mapOf(
DuckChatPixelParameters.DELTA_TIMESTAMP_PARAMETERS to duckChatFeatureRepository.sessionDeltaInMinutes().toString(),
)
val (pixelName, params) = when (reportMetric) {
USER_DID_SUBMIT_PROMPT -> DUCK_CHAT_SEND_PROMPT_ONGOING_CHAT to sessionParams
USER_DID_SUBMIT_FIRST_PROMPT -> DUCK_CHAT_START_NEW_CONVERSATION to sessionParams
USER_DID_SUBMIT_PROMPT -> {
refreshAtb = true
DUCK_CHAT_SEND_PROMPT_ONGOING_CHAT to sessionParams
}
USER_DID_SUBMIT_FIRST_PROMPT -> {
refreshAtb = true
DUCK_CHAT_START_NEW_CONVERSATION to sessionParams
}
USER_DID_OPEN_HISTORY -> DUCK_CHAT_OPEN_HISTORY to sessionParams
USER_DID_SELECT_FIRST_HISTORY_ITEM -> DUCK_CHAT_OPEN_MOST_RECENT_HISTORY_CHAT to sessionParams
USER_DID_CREATE_NEW_CHAT -> DUCK_CHAT_START_NEW_CONVERSATION_BUTTON_CLICKED to sessionParams
Expand All @@ -138,6 +147,9 @@ class RealDuckChatPixels @Inject constructor(

withContext(dispatcherProvider.main()) {
pixel.fire(pixelName, parameters = params)
if (refreshAtb) {
statisticsUpdater.refreshDuckAiRetentionAtb()
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package com.duckduckgo.duckchat.impl.pixel

import com.duckduckgo.app.statistics.api.StatisticsUpdater
import com.duckduckgo.app.statistics.pixels.Pixel
import com.duckduckgo.common.test.CoroutineTestRule
import com.duckduckgo.duckchat.impl.ReportMetric.USER_DID_CREATE_NEW_CHAT
Expand All @@ -39,6 +40,7 @@ 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

@OptIn(ExperimentalCoroutinesApi::class)
Expand All @@ -50,6 +52,8 @@ class RealDuckChatPixelsTest {
private val mockPixel: Pixel = mock()
private val mockDuckChatFeatureRepository: DuckChatFeatureRepository = mock()

private val statisticsUpdater: StatisticsUpdater = mock()

private lateinit var testee: RealDuckChatPixels

@Before
Expand All @@ -61,6 +65,7 @@ class RealDuckChatPixelsTest {
duckChatFeatureRepository = mockDuckChatFeatureRepository,
appCoroutineScope = coroutineRule.testScope,
dispatcherProvider = coroutineRule.testDispatcherProvider,
statisticsUpdater = statisticsUpdater,
)
}

Expand All @@ -76,6 +81,7 @@ class RealDuckChatPixelsTest {
DUCK_CHAT_SEND_PROMPT_ONGOING_CHAT,
parameters = mapOf(DuckChatPixelParameters.DELTA_TIMESTAMP_PARAMETERS to "5"),
)
verify(statisticsUpdater).refreshDuckAiRetentionAtb()
}

@Test
Expand All @@ -90,6 +96,7 @@ class RealDuckChatPixelsTest {
DUCK_CHAT_START_NEW_CONVERSATION,
parameters = mapOf(DuckChatPixelParameters.DELTA_TIMESTAMP_PARAMETERS to "10"),
)
verify(statisticsUpdater).refreshDuckAiRetentionAtb()
}

@Test
Expand All @@ -104,6 +111,7 @@ class RealDuckChatPixelsTest {
DUCK_CHAT_OPEN_HISTORY,
parameters = mapOf(DuckChatPixelParameters.DELTA_TIMESTAMP_PARAMETERS to "15"),
)
verifyNoInteractions(statisticsUpdater)
}

@Test
Expand All @@ -118,6 +126,7 @@ class RealDuckChatPixelsTest {
DUCK_CHAT_OPEN_MOST_RECENT_HISTORY_CHAT,
parameters = mapOf(DuckChatPixelParameters.DELTA_TIMESTAMP_PARAMETERS to "20"),
)
verifyNoInteractions(statisticsUpdater)
}

@Test
Expand All @@ -132,6 +141,7 @@ class RealDuckChatPixelsTest {
DUCK_CHAT_START_NEW_CONVERSATION_BUTTON_CLICKED,
parameters = mapOf(DuckChatPixelParameters.DELTA_TIMESTAMP_PARAMETERS to "25"),
)
verifyNoInteractions(statisticsUpdater)
}

@Test
Expand All @@ -141,5 +151,6 @@ class RealDuckChatPixelsTest {
advanceUntilIdle()

verify(mockPixel).fire(DUCK_CHAT_KEYBOARD_RETURN_PRESSED, parameters = emptyMap())
verifyNoInteractions(statisticsUpdater)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ interface AtbLifecyclePlugin {
// default is no-op
}

/**
* Will be called right after we have refreshed the ATB retention on duck.ai
*/
fun onDuckAiRetentionAtbRefreshed(oldAtb: String, newAtb: String) {
// default is no-op
}

/**
* Will be called right after the ATB is first initialized and successfully sent via exti call
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*
* 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.statistics.api

interface StatisticsUpdater {
fun refreshSearchRetentionAtb()
fun refreshDuckAiRetentionAtb()
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ interface StatisticsDataStore {
var atb: Atb?
var appRetentionAtb: String?
var searchRetentionAtb: String?
var duckaiRetentionAtb: String?
var variant: String?
var referrerVariant: String?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import androidx.lifecycle.LifecycleOwner
import com.duckduckgo.anvil.annotations.ContributesPluginPoint
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver
import com.duckduckgo.app.statistics.api.StatisticsUpdater
import com.duckduckgo.app.statistics.api.StatisticsRequester
import com.duckduckgo.app.statistics.pixels.Pixel
import com.duckduckgo.app.statistics.pixels.StatisticsPixelName.ATB_PRE_INITIALIZER_PLUGIN_TIMEOUT
import com.duckduckgo.app.statistics.store.StatisticsDataStore
Expand Down Expand Up @@ -50,7 +50,7 @@ import javax.inject.Inject
class AtbInitializer @Inject constructor(
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
private val statisticsDataStore: StatisticsDataStore,
private val statisticsUpdater: StatisticsUpdater,
private val statisticsUpdater: StatisticsRequester,
private val listeners: PluginPoint<AtbInitializerListener>,
private val dispatcherProvider: DispatcherProvider,
private val pixel: Pixel,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import com.duckduckgo.common.utils.plugins.PluginPoint
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.experiments.api.VariantManager
import com.squareup.anvil.annotations.ContributesBinding
import dagger.SingleInstanceIn
import io.reactivex.schedulers.Schedulers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
Expand All @@ -35,13 +36,11 @@ import logcat.LogPriority.WARN
import logcat.logcat
import javax.inject.Inject

interface StatisticsUpdater {
fun initializeAtb()
fun refreshSearchRetentionAtb()
fun refreshAppRetentionAtb()
}

@ContributesBinding(AppScope::class)
@ContributesBinding(
scope = AppScope::class,
boundType = StatisticsUpdater::class,
)
@SingleInstanceIn(AppScope::class)
class StatisticsRequester @Inject constructor(
private val store: StatisticsDataStore,
private val service: StatisticsService,
Expand All @@ -57,7 +56,7 @@ class StatisticsRequester @Inject constructor(
* consume referer data
*/
@SuppressLint("CheckResult")
override fun initializeAtb() {
fun initializeAtb() {
logcat(INFO) { "Initializing ATB" }

if (store.hasInstallationStatistics) {
Expand Down Expand Up @@ -135,8 +134,39 @@ class StatisticsRequester @Inject constructor(
}
}

override fun refreshDuckAiRetentionAtb() {
val atb = store.atb

if (atb == null) {
initializeAtb()
return
}

appCoroutineScope.launch(dispatchers.io()) {
val fullAtb = atb.formatWithVariant(variantManager.getVariantKey())
val oldDuckAiAtb = store.duckaiRetentionAtb ?: atb.version

service
.updateDuckAiAtb(
atb = fullAtb,
retentionAtb = oldDuckAiAtb,
email = emailSignInState(),
)
.subscribeOn(Schedulers.io())
.subscribe(
{
logcat(VERBOSE) { "Duck.ai atb refresh succeeded, latest atb is ${it.version}" }
store.duckaiRetentionAtb = it.version
storeUpdateVersionIfPresent(it)
plugins.getPlugins().forEach { plugin -> plugin.onDuckAiRetentionAtbRefreshed(oldDuckAiAtb, it.version) }
},
{ logcat(VERBOSE) { "Duck.ai atb refresh failed with error ${it.localizedMessage}" } },
)
}
}

@SuppressLint("CheckResult")
override fun refreshAppRetentionAtb() {
fun refreshAppRetentionAtb() {
val atb = store.atb

if (atb == null) {
Expand Down
Loading
Loading