diff --git a/app/src/main/java/com/duckduckgo/app/global/install/AppInstallRepository.kt b/app/src/main/java/com/duckduckgo/app/global/install/AppInstallRepository.kt index eb902158483e..69c4f04612c9 100644 --- a/app/src/main/java/com/duckduckgo/app/global/install/AppInstallRepository.kt +++ b/app/src/main/java/com/duckduckgo/app/global/install/AppInstallRepository.kt @@ -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 + } + } } diff --git a/app/src/test/java/com/duckduckgo/app/global/install/AppInstallRepositoryTest.kt b/app/src/test/java/com/duckduckgo/app/global/install/AppInstallRepositoryTest.kt new file mode 100644 index 000000000000..7fb513bc2e2a --- /dev/null +++ b/app/src/test/java/com/duckduckgo/app/global/install/AppInstallRepositoryTest.kt @@ -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 + } +} diff --git a/browser-api/src/main/java/com/duckduckgo/browser/api/install/AppInstall.kt b/browser-api/src/main/java/com/duckduckgo/browser/api/install/AppInstall.kt index e3ec1c78f9b8..2e71a596adb8 100644 --- a/browser-api/src/main/java/com/duckduckgo/browser/api/install/AppInstall.kt +++ b/browser-api/src/main/java/com/duckduckgo/browser/api/install/AppInstall.kt @@ -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? } diff --git a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt index 21d71f6a4dc2..4fc8e9a7d973 100644 --- a/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt +++ b/duckchat/duckchat-impl/src/main/java/com/duckduckgo/duckchat/impl/helper/DuckChatJSHelper.kt @@ -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 @@ -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 @@ -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() @@ -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, diff --git a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt index dd945ca4529b..09ba76865f25 100644 --- a/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt +++ b/duckchat/duckchat-impl/src/test/kotlin/com/duckduckgo/duckchat/impl/helper/RealDuckChatJSHelperTest.kt @@ -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 @@ -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) @@ -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, @@ -109,7 +107,6 @@ class RealDuckChatJSHelperTest { nativeInputStatePublisher = mockNativeInputStatePublisher, appInstall = mockAppInstall, appBuildConfig = mockAppBuildConfig, - currentTimeProvider = mockCurrentTimeProvider, ) private val viewModel = object { @@ -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, @@ -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", @@ -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", @@ -401,8 +393,8 @@ class RealDuckChatJSHelperTest { } @Test - fun whenInstallTimestampNotRecordedThenInstallAgeIsOmitted() = runTest { - whenever(mockAppInstall.getInstallationTimestamp()).thenReturn(0L) + fun whenInstallAgeIsNullBecauseNotRecordedThenInstallAgeIsOmitted() = runTest { + whenever(mockAppInstall.getInstallAge()).thenReturn(null) val result = testee.processJsCallbackMessage( featureName = "aiChat", @@ -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 - } }