Skip to content
Open
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 @@ -17,17 +17,34 @@
package com.duckduckgo.app.global.install

import com.duckduckgo.browser.api.install.AppInstall
import com.duckduckgo.common.utils.CurrentTimeProvider
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesBinding
import dagger.SingleInstanceIn
import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds

@ContributesBinding(AppScope::class)
@SingleInstanceIn(AppScope::class)
class AppInstallRepository @Inject constructor(
private val appInstallStore: AppInstallStore,
private val currentTimeProvider: CurrentTimeProvider,
private val dispatcherProvider: DispatcherProvider,
) : AppInstall {
override fun getInstallationTimestamp(): Long {
return appInstallStore.installTimestamp
}

override suspend fun getInstallAge(): Duration? = withContext(dispatcherProvider.io()) {
val installTimestamp = appInstallStore.installTimestamp
val now = currentTimeProvider.currentTimeMillis()
if (installTimestamp <= 0L || installTimestamp > now) {
null
} else {
(now - installTimestamp).milliseconds
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* 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.global.install

import com.duckduckgo.common.test.CoroutineTestRule
import com.duckduckgo.common.utils.CurrentTimeProvider
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
import kotlin.time.Duration.Companion.days

@OptIn(ExperimentalCoroutinesApi::class)
class AppInstallRepositoryTest {

@get:Rule
val coroutineRule = CoroutineTestRule()

private val appInstallStore: AppInstallStore = mock()
private val currentTimeProvider: CurrentTimeProvider = mock()

private val testee = AppInstallRepository(
appInstallStore = appInstallStore,
currentTimeProvider = currentTimeProvider,
dispatcherProvider = coroutineRule.testDispatcherProvider,
)

@Test
fun whenInstallTimestampNotRecordedThenInstallAgeIsNull() = runTest {
whenever(appInstallStore.installTimestamp).thenReturn(0L)
whenever(currentTimeProvider.currentTimeMillis()).thenReturn(NOW)

assertNull(testee.getInstallAge())
}

@Test
fun whenInstallTimestampIsInTheFutureThenInstallAgeIsNull() = runTest {
whenever(appInstallStore.installTimestamp).thenReturn(NOW + 1.days.inWholeMilliseconds)
whenever(currentTimeProvider.currentTimeMillis()).thenReturn(NOW)

assertNull(testee.getInstallAge())
}

@Test
fun whenInstalledInThePastThenInstallAgeIsElapsedTime() = runTest {
whenever(appInstallStore.installTimestamp).thenReturn(NOW - 5.days.inWholeMilliseconds)
whenever(currentTimeProvider.currentTimeMillis()).thenReturn(NOW)

assertEquals(5.days, testee.getInstallAge())
}

companion object {
private const val NOW = 1_700_000_000_000L
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@

package com.duckduckgo.browser.api.install

import kotlin.time.Duration

interface AppInstall {
fun getInstallationTimestamp(): Long

/**
* Elapsed time since installation. Can be null in rare edge cases (install timestamp not recorded
* or in the future due to clock skew).
*/
suspend fun getInstallAge(): Duration?
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import com.duckduckgo.appbuildconfig.api.AppBuildConfig
import com.duckduckgo.browser.api.install.AppInstall
import com.duckduckgo.common.ui.view.encodeBitmapToBase64
import com.duckduckgo.common.utils.ConflatedJob
import com.duckduckgo.common.utils.CurrentTimeProvider
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.duckchat.api.nativeinput.NativeInputStatePublisher
Expand All @@ -45,12 +44,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import logcat.logcat
import org.json.JSONArray
import org.json.JSONObject
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.regex.Pattern
import javax.inject.Inject

Expand Down Expand Up @@ -110,7 +106,6 @@ class RealDuckChatJSHelper @Inject constructor(
private val nativeInputStatePublisher: NativeInputStatePublisher,
private val appInstall: AppInstall,
private val appBuildConfig: AppBuildConfig,
private val currentTimeProvider: CurrentTimeProvider,
) : DuckChatJSHelper {

private val registerOpenedJob = ConflatedJob()
Expand Down Expand Up @@ -422,25 +417,19 @@ class RealDuckChatJSHelper @Inject constructor(
return JsCallbackData(jsonPayload, featureName, method, id)
}

// Bucketed install age for the Duck.ai prompt pixel; null when the install timestamp isn't recorded
// yet or is in the future (clock skew), so the caller omits the param instead of sending a
// Bucketed install age for the Duck.ai prompt pixel; null when there's no valid age (timestamp
// not recorded yet or in the future), so the caller omits the param instead of sending a
// misleading bucket.
private suspend fun getInstallAgeBucket(): Int? {
val installTimestamp = withContext(dispatcherProvider.io()) { appInstall.getInstallationTimestamp() }
val nowTimestamp = currentTimeProvider.currentTimeMillis()
if (installTimestamp <= 0L || installTimestamp > nowTimestamp) return null
val installedAt = Instant.ofEpochMilli(installTimestamp)
val now = Instant.ofEpochMilli(nowTimestamp)
val days = ChronoUnit.DAYS.between(installedAt, now)
return when {
days == 0L -> 0
days <= 7L -> 1
days <= 14L -> 2
days <= 21L -> 3
days <= 28L -> 4
private suspend fun getInstallAgeBucket(): Int? =
when (appInstall.getInstallAge()?.inWholeDays) {
null -> null
0L -> 0
in 1L..7L -> 1
in 8L..14L -> 2
in 15L..21L -> 3
in 22L..28L -> 4
else -> 5
}
}

private fun getAIChatNativePrompt(
featureName: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import com.duckduckgo.app.browser.favicon.FaviconManager
import com.duckduckgo.appbuildconfig.api.AppBuildConfig
import com.duckduckgo.browser.api.install.AppInstall
import com.duckduckgo.common.test.CoroutineTestRule
import com.duckduckgo.common.utils.CurrentTimeProvider
import com.duckduckgo.duckchat.api.nativeinput.NativeInputState
import com.duckduckgo.duckchat.api.nativeinput.NativeInputStatePublisher
import com.duckduckgo.duckchat.impl.ChatState
Expand Down Expand Up @@ -66,6 +65,8 @@ import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.verifyNoInteractions
import org.mockito.kotlin.whenever
import kotlin.time.Duration
import kotlin.time.Duration.Companion.days

@RunWith(AndroidJUnit4::class)
@OptIn(ExperimentalCoroutinesApi::class)
Expand All @@ -86,14 +87,11 @@ class RealDuckChatJSHelperTest {
private val mockLimitsHandler: LimitsHandler = mock()
private val mockNativeInputStatePublisher: NativeInputStatePublisher = mock()
private val mockAppInstall: AppInstall = mock {
on { getInstallationTimestamp() } doReturn DEFAULT_INSTALL_TIMESTAMP
onBlocking { getInstallAge() } doReturn Duration.ZERO
}
private val mockAppBuildConfig: AppBuildConfig = mock {
onBlocking { isAppReinstall() } doReturn false
}
private val mockCurrentTimeProvider: CurrentTimeProvider = mock {
on { currentTimeMillis() } doReturn DEFAULT_INSTALL_TIMESTAMP
}
private val testee = RealDuckChatJSHelper(
duckChat = mockDuckChat,
duckChatPixels = mockDuckChatPixels,
Expand All @@ -109,7 +107,6 @@ class RealDuckChatJSHelperTest {
nativeInputStatePublisher = mockNativeInputStatePublisher,
appInstall = mockAppInstall,
appBuildConfig = mockAppBuildConfig,
currentTimeProvider = mockCurrentTimeProvider,
)
private val viewModel =
object {
Expand Down Expand Up @@ -348,9 +345,6 @@ class RealDuckChatJSHelperTest {

@Test
fun whenGetAIChatNativeConfigValuesThenInstallAgeIsBucketedByDaysSinceInstall() = runTest {
val now = 1_700_000_000_000L
whenever(mockCurrentTimeProvider.currentTimeMillis()).thenReturn(now)

val daysToExpectedBucket = mapOf(
0L to 0,
1L to 1,
Expand All @@ -365,7 +359,7 @@ class RealDuckChatJSHelperTest {
)

daysToExpectedBucket.forEach { (days, expectedBucket) ->
whenever(mockAppInstall.getInstallationTimestamp()).thenReturn(now - days * DAY_MILLIS)
whenever(mockAppInstall.getInstallAge()).thenReturn(days.days)

val result = testee.processJsCallbackMessage(
featureName = "aiChat",
Expand All @@ -384,10 +378,8 @@ class RealDuckChatJSHelperTest {
}

@Test
fun whenInstallTimestampIsInFutureThenInstallAgeIsOmitted() = runTest {
val now = 1_700_000_000_000L
whenever(mockCurrentTimeProvider.currentTimeMillis()).thenReturn(now)
whenever(mockAppInstall.getInstallationTimestamp()).thenReturn(now + 5 * DAY_MILLIS)
fun whenInstallAgeIsNullBecauseInFutureThenInstallAgeIsOmitted() = runTest {
whenever(mockAppInstall.getInstallAge()).thenReturn(null)

val result = testee.processJsCallbackMessage(
featureName = "aiChat",
Expand All @@ -401,8 +393,8 @@ class RealDuckChatJSHelperTest {
}

@Test
fun whenInstallTimestampNotRecordedThenInstallAgeIsOmitted() = runTest {
whenever(mockAppInstall.getInstallationTimestamp()).thenReturn(0L)
fun whenInstallAgeIsNullBecauseNotRecordedThenInstallAgeIsOmitted() = runTest {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: now that the future/not-recorded distinction lives in getInstallAge(), this test and whenInstallAgeIsNullBecauseInFutureThenInstallAgeIsOmitted above have identical bodies (getInstallAge() → null → omitted). the two scenarios are already covered properly in AppInstallRepositoryTest, so we could collapse these into one "null age → param omitted" test here.

whenever(mockAppInstall.getInstallAge()).thenReturn(null)

val result = testee.processJsCallbackMessage(
featureName = "aiChat",
Expand Down Expand Up @@ -2106,9 +2098,4 @@ class RealDuckChatJSHelperTest {

verify(mockVoiceSessionStateManager, never()).onVoiceSessionEnded(any())
}

companion object {
private const val DAY_MILLIS = 24L * 60 * 60 * 1000
private const val DEFAULT_INSTALL_TIMESTAMP = 1_700_000_000_000L
}
}
Loading