diff --git a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/ApiWideEventSender.kt b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/ApiWideEventSender.kt new file mode 100644 index 000000000000..58b77f84a868 --- /dev/null +++ b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/ApiWideEventSender.kt @@ -0,0 +1,108 @@ +/* + * 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.wideevents + +import com.duckduckgo.app.statistics.wideevents.api.AppSection +import com.duckduckgo.app.statistics.wideevents.api.ContextSection +import com.duckduckgo.app.statistics.wideevents.api.FeatureData +import com.duckduckgo.app.statistics.wideevents.api.FeatureSection +import com.duckduckgo.app.statistics.wideevents.api.GlobalSection +import com.duckduckgo.app.statistics.wideevents.api.WideEventRequest +import com.duckduckgo.app.statistics.wideevents.api.WideEventService +import com.duckduckgo.app.statistics.wideevents.db.WideEventRepository +import com.duckduckgo.app.statistics.wideevents.db.WideEventRepository.WideEventStatus.CANCELLED +import com.duckduckgo.app.statistics.wideevents.db.WideEventRepository.WideEventStatus.FAILURE +import com.duckduckgo.app.statistics.wideevents.db.WideEventRepository.WideEventStatus.SUCCESS +import com.duckduckgo.app.statistics.wideevents.db.WideEventRepository.WideEventStatus.UNKNOWN +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.common.utils.device.DeviceInfo +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import javax.inject.Inject +import javax.inject.Named + +@ContributesBinding(AppScope::class) +@Named("api") +class ApiWideEventSender @Inject constructor( + private val wideEventService: WideEventService, + private val appBuildConfig: AppBuildConfig, + private val deviceInfo: DeviceInfo, +) : WideEventSender { + + override suspend fun sendWideEvent(event: WideEventRepository.WideEvent) { + requireNotNull(event.status) { "Attempting to send wide event with null status" } + + val request = WideEventRequest( + global = GlobalSection( + platform = PLATFORM, + type = TYPE, + sampleRate = SAMPLE_RATE, + ), + app = AppSection( + name = APP_NAME, + version = appBuildConfig.versionName, + formFactor = deviceInfo.formFactor().description, + devMode = appBuildConfig.isDebug.toString(), + ), + feature = FeatureSection( + name = event.name, + status = event.status.toParamValue(), + data = buildFeatureData(event), + ), + context = event.flowEntryPoint?.let { ContextSection(name = it) }, + ) + + wideEventService.sendWideEvent(request) + } + + private fun buildFeatureData(event: WideEventRepository.WideEvent): FeatureData? { + val extData = mutableMapOf() + + // Add metadata as ext data + event.metadata + .filterValues { it != null } + .forEach { (key, value) -> + extData[key] = value!! + } + + // Add steps as ext data with "step." prefix + event.steps.forEach { (name, success) -> + extData["step.$name"] = success.toString() + } + + return if (extData.isNotEmpty()) { + FeatureData(ext = extData) + } else { + null + } + } + + private companion object { + const val PLATFORM = "Android" + const val TYPE = "app" + const val SAMPLE_RATE = 1 + const val APP_NAME = "DuckDuckGo Android" + } +} + +private fun WideEventRepository.WideEventStatus.toParamValue(): String = + when (this) { + SUCCESS -> "SUCCESS" + FAILURE -> "FAILURE" + CANCELLED -> "CANCELLED" + UNKNOWN -> "UNKNOWN" + } diff --git a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/CompletedWideEventsProcessor.kt b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/CompletedWideEventsProcessor.kt index 6d8e51e49c44..5eaf975640a0 100644 --- a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/CompletedWideEventsProcessor.kt +++ b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/CompletedWideEventsProcessor.kt @@ -16,7 +16,16 @@ package com.duckduckgo.app.statistics.wideevents +import android.content.Context import androidx.lifecycle.LifecycleOwner +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.duckduckgo.anvil.annotations.ContributesWorker import com.duckduckgo.app.di.AppCoroutineScope import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver import com.duckduckgo.app.statistics.wideevents.db.WideEventRepository @@ -25,10 +34,15 @@ import com.duckduckgo.di.scopes.AppScope import com.squareup.anvil.annotations.ContributesMultibinding import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import logcat.logcat import javax.inject.Inject +import kotlin.collections.chunked +import kotlin.collections.toSet @OptIn(ExperimentalCoroutinesApi::class) @ContributesMultibinding(AppScope::class) @@ -38,29 +52,42 @@ class CompletedWideEventsProcessor @Inject constructor( @AppCoroutineScope private val appCoroutineScope: CoroutineScope, private val wideEventFeature: WideEventFeature, private val dispatcherProvider: DispatcherProvider, + private val workManager: WorkManager, ) : MainProcessLifecycleObserver { + + private val mutex = Mutex() + override fun onCreate(owner: LifecycleOwner) { appCoroutineScope.launch { runCatching { if (!isFeatureEnabled()) return@runCatching wideEventRepository - .getCompletedWideEventIdsFlow() - .conflate() - .collect { ids -> - // Process events in chunks to avoid querying too many events at once. - ids.chunked(100).forEach { idsChunk -> - processCompletedWideEvents(idsChunk.toSet()) + .hasCompletedWideEvents() + .filter { it } + .collect { + try { + processCompletedWideEvents() + } catch (e: Exception) { + logcat { "Failed to process completed wide events: ${e.stackTraceToString()}" } + scheduleRetry() } } } } } - private suspend fun processCompletedWideEvents(wideEventIds: Set) { - wideEventRepository.getWideEvents(wideEventIds).forEach { event -> - wideEventSender.sendWideEvent(event) - wideEventRepository.deleteWideEvent(event.id) + suspend fun processCompletedWideEvents() { + if (!isFeatureEnabled()) return + + mutex.withLock { + // Process events in chunks to avoid querying too many events at once. + wideEventRepository.getCompletedWideEventIds().chunked(100).forEach { idsChunk -> + wideEventRepository.getWideEvents(idsChunk.toSet()).forEach { event -> + wideEventSender.sendWideEvent(event) + wideEventRepository.deleteWideEvent(event.id) + } + } } } @@ -68,4 +95,37 @@ class CompletedWideEventsProcessor @Inject constructor( withContext(dispatcherProvider.io()) { wideEventFeature.self().isEnabled() } + + private fun scheduleRetry() { + workManager.enqueueUniqueWork( + TAG_WORKER_COMPLETED_WIDE_EVENTS, + ExistingWorkPolicy.KEEP, + OneTimeWorkRequestBuilder() + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .build(), + ) + } + + companion object { + const val TAG_WORKER_COMPLETED_WIDE_EVENTS = "TAG_WORKER_COMPLETED_WIDE_EVENTS" + } +} + +@ContributesWorker(AppScope::class) +class CompletedWideEventsWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + + @Inject + lateinit var completedWideEventsProcessor: CompletedWideEventsProcessor + + override suspend fun doWork(): Result { + return try { + completedWideEventsProcessor.processCompletedWideEvents() + Result.success() + } catch (_: Exception) { + Result.retry() + } + } } diff --git a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/DelegatingWideEventSender.kt b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/DelegatingWideEventSender.kt new file mode 100644 index 000000000000..2c811ba1c104 --- /dev/null +++ b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/DelegatingWideEventSender.kt @@ -0,0 +1,51 @@ +/* + * 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.wideevents + +import com.duckduckgo.app.statistics.wideevents.db.WideEventRepository +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Named + +@ContributesBinding(AppScope::class) +class DelegatingWideEventSender @Inject constructor( + @Named("pixel") private val pixelWideEventSender: WideEventSender, + @Named("api") private val apiWideEventSender: WideEventSender, + private val wideEventFeature: WideEventFeature, + private val dispatcherProvider: DispatcherProvider, +) : WideEventSender { + + override suspend fun sendWideEvent(event: WideEventRepository.WideEvent) { + if (shouldUseApiSender()) { + apiWideEventSender.sendWideEvent(event) + } + if (shouldUsePixelSender()) { + pixelWideEventSender.sendWideEvent(event) + } + } + + private suspend fun shouldUsePixelSender(): Boolean = withContext(dispatcherProvider.io()) { + wideEventFeature.sendWideEventsViaPixels().isEnabled() + } + + private suspend fun shouldUseApiSender(): Boolean = withContext(dispatcherProvider.io()) { + wideEventFeature.sendWideEventsViaPost().isEnabled() + } +} diff --git a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/PixelWideEventSender.kt b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/PixelWideEventSender.kt new file mode 100644 index 000000000000..2c6a7da9b1f7 --- /dev/null +++ b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/PixelWideEventSender.kt @@ -0,0 +1,143 @@ +/* + * 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.wideevents + +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.wideevents.db.WideEventRepository +import com.duckduckgo.app.statistics.wideevents.db.WideEventRepository.WideEventStatus.CANCELLED +import com.duckduckgo.app.statistics.wideevents.db.WideEventRepository.WideEventStatus.FAILURE +import com.duckduckgo.app.statistics.wideevents.db.WideEventRepository.WideEventStatus.SUCCESS +import com.duckduckgo.app.statistics.wideevents.db.WideEventRepository.WideEventStatus.UNKNOWN +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.common.utils.DispatcherProvider +import com.duckduckgo.common.utils.device.DeviceInfo +import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin +import com.duckduckgo.di.scopes.AppScope +import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.ContributesMultibinding +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Named + +@ContributesBinding(AppScope::class) +@Named("pixel") +class PixelWideEventSender @Inject constructor( + private val wideEventFeature: WideEventFeature, + private val dispatchers: DispatcherProvider, + private val pixelSender: Pixel, + private val appBuildConfig: AppBuildConfig, + private val deviceInfo: DeviceInfo, +) : WideEventSender { + override suspend fun sendWideEvent(event: WideEventRepository.WideEvent) { + requireNotNull(event.status) { "Attempting to send wide event with null status" } + + val parameters = + mutableMapOf().apply { + putAll(getCommonPixelParameters()) + put(PARAM_STATUS, event.status.toParamValue()) + + if (event.flowEntryPoint != null) { + put(PARAM_CONTEXT_NAME, event.flowEntryPoint) + } + + event.steps.forEach { (name, success) -> + put(PARAM_METADATA_STEP_PREFIX + name, success.toString()) + } + } + + val encodedParameters = + event.metadata + .filterValues { it != null } + .mapValues { it.value!! } + .mapKeys { PARAM_METADATA_PREFIX + it.key } + + val basePixelName = PIXEL_NAME_PREFIX + event.name + val countPixelName = basePixelName + COUNT_PIXEL_SUFFIX + + if (shouldEnqueuePixel()) { + pixelSender.enqueueFire( + pixelName = countPixelName, + parameters = parameters, + encodedParameters = encodedParameters, + ) + } else { + pixelSender.fire( + pixelName = countPixelName, + parameters = parameters, + encodedParameters = encodedParameters, + type = Pixel.PixelType.Count, + ) + } + + pixelSender.fire( + pixelName = basePixelName + DAILY_PIXEL_SUFFIX, + parameters = parameters, + encodedParameters = encodedParameters, + type = Pixel.PixelType.Daily(), + ) + } + + private fun getCommonPixelParameters(): Map { + return mapOf( + PARAM_PLATFORM to "Android", + PARAM_TYPE to "app", + PARAM_SAMPLE_RATE to "1", + PARAM_APP_NAME to "DuckDuckGo Android", + PARAM_APP_VERSION to appBuildConfig.versionName, + PARAM_FORM_FACTOR to deviceInfo.formFactor().description, + PARAM_DEV_MODE to appBuildConfig.isDebug.toString(), + ) + } + + private suspend fun shouldEnqueuePixel() = withContext(dispatchers.io()) { + wideEventFeature.enqueueWideEventPixels().isEnabled() + } + + private companion object { + const val COUNT_PIXEL_SUFFIX = "_c" + const val DAILY_PIXEL_SUFFIX = "_d" + + const val PARAM_PLATFORM = "global.platform" + const val PARAM_TYPE = "global.type" + const val PARAM_SAMPLE_RATE = "global.sample_rate" + const val PARAM_CONTEXT_NAME = "context.name" + const val PARAM_STATUS = "feature.status" + const val PARAM_APP_NAME = "app.name" + const val PARAM_APP_VERSION = "app.version" + const val PARAM_FORM_FACTOR = "app.form_factor" + const val PARAM_DEV_MODE = "app.dev_mode" + + const val PARAM_METADATA_PREFIX = "feature.data.ext." + const val PARAM_METADATA_STEP_PREFIX = PARAM_METADATA_PREFIX + "step." + } +} + +private fun WideEventRepository.WideEventStatus.toParamValue(): String = + when (this) { + SUCCESS -> "SUCCESS" + FAILURE -> "FAILURE" + CANCELLED -> "CANCELLED" + UNKNOWN -> "UNKNOWN" + } + +@ContributesMultibinding(AppScope::class) +class WideEventPixelParamRemovalPlugin @Inject constructor() : PixelParamRemovalPlugin { + override fun names(): List>> = + listOf(PIXEL_NAME_PREFIX to PixelParamRemovalPlugin.PixelParameter.Companion.removeAll()) +} + +private const val PIXEL_NAME_PREFIX = "wide_" diff --git a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/WideEventFeature.kt b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/WideEventFeature.kt index f810c4748d91..f05e059634c5 100644 --- a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/WideEventFeature.kt +++ b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/WideEventFeature.kt @@ -31,4 +31,16 @@ interface WideEventFeature { @Toggle.DefaultValue(DefaultFeatureValue.TRUE) fun enqueueWideEventPixels(): Toggle + + /** + * When enabled, wide events are sent as pixels. + */ + @Toggle.DefaultValue(DefaultFeatureValue.TRUE) + fun sendWideEventsViaPixels(): Toggle + + /** + * When enabled, wide events are sent via dedicated POST endpoint. + */ + @Toggle.DefaultValue(DefaultFeatureValue.INTERNAL) + fun sendWideEventsViaPost(): Toggle } diff --git a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/WideEventSender.kt b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/WideEventSender.kt index 550a622c2ea9..516d7bd55b58 100644 --- a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/WideEventSender.kt +++ b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/WideEventSender.kt @@ -16,131 +16,8 @@ package com.duckduckgo.app.statistics.wideevents -import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.wideevents.db.WideEventRepository -import com.duckduckgo.app.statistics.wideevents.db.WideEventRepository.WideEventStatus.CANCELLED -import com.duckduckgo.app.statistics.wideevents.db.WideEventRepository.WideEventStatus.FAILURE -import com.duckduckgo.app.statistics.wideevents.db.WideEventRepository.WideEventStatus.SUCCESS -import com.duckduckgo.app.statistics.wideevents.db.WideEventRepository.WideEventStatus.UNKNOWN -import com.duckduckgo.appbuildconfig.api.AppBuildConfig -import com.duckduckgo.common.utils.DispatcherProvider -import com.duckduckgo.common.utils.device.DeviceInfo -import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin -import com.duckduckgo.common.utils.plugins.pixel.PixelParamRemovalPlugin.PixelParameter -import com.duckduckgo.di.scopes.AppScope -import com.squareup.anvil.annotations.ContributesBinding -import com.squareup.anvil.annotations.ContributesMultibinding -import kotlinx.coroutines.withContext -import javax.inject.Inject interface WideEventSender { suspend fun sendWideEvent(event: WideEventRepository.WideEvent) } - -@ContributesBinding(AppScope::class) -class PixelWideEventSender @Inject constructor( - private val wideEventFeature: WideEventFeature, - private val dispatchers: DispatcherProvider, - private val pixelSender: Pixel, - private val appBuildConfig: AppBuildConfig, - private val deviceInfo: DeviceInfo, -) : WideEventSender { - override suspend fun sendWideEvent(event: WideEventRepository.WideEvent) { - requireNotNull(event.status) { "Attempting to send wide event with null status" } - - val parameters = - mutableMapOf().apply { - putAll(getCommonPixelParameters()) - put(PARAM_STATUS, event.status.toParamValue()) - - if (event.flowEntryPoint != null) { - put(PARAM_CONTEXT_NAME, event.flowEntryPoint) - } - - event.steps.forEach { (name, success) -> - put(PARAM_METADATA_STEP_PREFIX + name, success.toString()) - } - } - - val encodedParameters = - event.metadata - .filterValues { it != null } - .mapValues { it.value!! } - .mapKeys { PARAM_METADATA_PREFIX + it.key } - - val basePixelName = PIXEL_NAME_PREFIX + event.name - val countPixelName = basePixelName + COUNT_PIXEL_SUFFIX - - if (shouldEnqueuePixel()) { - pixelSender.enqueueFire( - pixelName = countPixelName, - parameters = parameters, - encodedParameters = encodedParameters, - ) - } else { - pixelSender.fire( - pixelName = countPixelName, - parameters = parameters, - encodedParameters = encodedParameters, - type = Pixel.PixelType.Count, - ) - } - - pixelSender.fire( - pixelName = basePixelName + DAILY_PIXEL_SUFFIX, - parameters = parameters, - encodedParameters = encodedParameters, - type = Pixel.PixelType.Daily(), - ) - } - - private fun getCommonPixelParameters(): Map { - return mapOf( - PARAM_PLATFORM to "Android", - PARAM_TYPE to "app", - PARAM_SAMPLE_RATE to "1", - PARAM_APP_NAME to "DuckDuckGo Android", - PARAM_APP_VERSION to appBuildConfig.versionName, - PARAM_FORM_FACTOR to deviceInfo.formFactor().description, - PARAM_DEV_MODE to appBuildConfig.isDebug.toString(), - ) - } - - private suspend fun shouldEnqueuePixel() = withContext(dispatchers.io()) { - wideEventFeature.enqueueWideEventPixels().isEnabled() - } - - private companion object { - const val COUNT_PIXEL_SUFFIX = "_c" - const val DAILY_PIXEL_SUFFIX = "_d" - - const val PARAM_PLATFORM = "global.platform" - const val PARAM_TYPE = "global.type" - const val PARAM_SAMPLE_RATE = "global.sample_rate" - const val PARAM_CONTEXT_NAME = "context.name" - const val PARAM_STATUS = "feature.status" - const val PARAM_APP_NAME = "app.name" - const val PARAM_APP_VERSION = "app.version" - const val PARAM_FORM_FACTOR = "app.form_factor" - const val PARAM_DEV_MODE = "app.dev_mode" - - const val PARAM_METADATA_PREFIX = "feature.data.ext." - const val PARAM_METADATA_STEP_PREFIX = PARAM_METADATA_PREFIX + "step." - } -} - -private fun WideEventRepository.WideEventStatus.toParamValue(): String = - when (this) { - SUCCESS -> "SUCCESS" - FAILURE -> "FAILURE" - CANCELLED -> "CANCELLED" - UNKNOWN -> "UNKNOWN" - } - -@ContributesMultibinding(AppScope::class) -class WideEventPixelParamRemovalPlugin @Inject constructor() : PixelParamRemovalPlugin { - override fun names(): List>> = - listOf(PIXEL_NAME_PREFIX to PixelParameter.removeAll()) -} - -private const val PIXEL_NAME_PREFIX = "wide_" diff --git a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/api/WideEventRequest.kt b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/api/WideEventRequest.kt new file mode 100644 index 000000000000..0a3c69b5bb36 --- /dev/null +++ b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/api/WideEventRequest.kt @@ -0,0 +1,53 @@ +/* + * 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.wideevents.api + +import com.squareup.moshi.Json + +data class WideEventRequest( + @field:Json(name = "global") val global: GlobalSection, + @field:Json(name = "app") val app: AppSection, + @field:Json(name = "feature") val feature: FeatureSection, + @field:Json(name = "context") val context: ContextSection?, +) + +data class GlobalSection( + @field:Json(name = "platform") val platform: String, + @field:Json(name = "type") val type: String, + @field:Json(name = "sample_rate") val sampleRate: Int, +) + +data class AppSection( + @field:Json(name = "name") val name: String, + @field:Json(name = "version") val version: String, + @field:Json(name = "form_factor") val formFactor: String, + @field:Json(name = "dev_mode") val devMode: String, +) + +data class FeatureSection( + @field:Json(name = "name") val name: String, + @field:Json(name = "status") val status: String, + @field:Json(name = "data") val data: FeatureData?, +) + +data class FeatureData( + @field:Json(name = "ext") val ext: Map?, +) + +data class ContextSection( + @field:Json(name = "name") val name: String, +) diff --git a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/api/WideEventService.kt b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/api/WideEventService.kt new file mode 100644 index 000000000000..f4be315fd079 --- /dev/null +++ b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/api/WideEventService.kt @@ -0,0 +1,30 @@ +/* + * 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.wideevents.api + +import com.duckduckgo.anvil.annotations.ContributesNonCachingServiceApi +import com.duckduckgo.common.utils.AppUrl +import com.duckduckgo.di.scopes.AppScope +import retrofit2.http.Body +import retrofit2.http.POST + +@ContributesNonCachingServiceApi(AppScope::class) +interface WideEventService { + + @POST("${AppUrl.Url.PIXEL}/e") + suspend fun sendWideEvent(@Body request: WideEventRequest) +} diff --git a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/db/WideEventDao.kt b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/db/WideEventDao.kt index f6e9d7abaeb9..c101bc3223f6 100644 --- a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/db/WideEventDao.kt +++ b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/db/WideEventDao.kt @@ -36,6 +36,9 @@ interface WideEventDao { @Query("SELECT id FROM wide_events WHERE status is null ORDER BY id ASC") suspend fun getActiveWideEventIds(): List + @Query("SELECT id FROM wide_events WHERE status is not null ORDER BY id ASC") + suspend fun getCompletedWideEventIds(): List + @Query("SELECT id FROM wide_events WHERE name = :name AND status is null ORDER BY id ASC") suspend fun getActiveWideEventIdsByName(name: String): List @@ -45,6 +48,6 @@ interface WideEventDao { @Query("DELETE FROM wide_events WHERE id = :id") suspend fun deleteWideEvent(id: Long): Int - @Query("SELECT id FROM wide_events WHERE status is not null") - fun getCompletedWideEventIdsFlow(): Flow> + @Query("SELECT EXISTS(SELECT 1 FROM wide_events WHERE status is not null)") + fun hasCompletedWideEvents(): Flow } diff --git a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/db/WideEventRepository.kt b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/db/WideEventRepository.kt index 19b3477c4498..b5662f72e279 100644 --- a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/db/WideEventRepository.kt +++ b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/db/WideEventRepository.kt @@ -44,9 +44,11 @@ interface WideEventRepository { suspend fun getActiveWideEventIds(): List + suspend fun getCompletedWideEventIds(): List + suspend fun getActiveWideEventIdsByName(eventName: String): List - fun getCompletedWideEventIdsFlow(): Flow> + fun hasCompletedWideEvents(): Flow suspend fun getWideEvents(ids: Set): List diff --git a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/db/WideEventRepositoryImpl.kt b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/db/WideEventRepositoryImpl.kt index 062aa786809d..8d32f48bc902 100644 --- a/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/db/WideEventRepositoryImpl.kt +++ b/statistics/statistics-impl/src/main/java/com/duckduckgo/app/statistics/wideevents/db/WideEventRepositoryImpl.kt @@ -90,11 +90,13 @@ class WideEventRepositoryImpl @Inject constructor( override suspend fun getActiveWideEventIds(): List = wideEventDao.getActiveWideEventIds() + override suspend fun getCompletedWideEventIds(): List = wideEventDao.getCompletedWideEventIds() + override suspend fun getActiveWideEventIdsByName(eventName: String): List = wideEventDao.getActiveWideEventIdsByName(eventName) override suspend fun deleteWideEvent(eventId: Long): Boolean = wideEventDao.deleteWideEvent(eventId) > 0 - override fun getCompletedWideEventIdsFlow(): Flow> = wideEventDao.getCompletedWideEventIdsFlow().map { it.toSet() } + override fun hasCompletedWideEvents(): Flow = wideEventDao.hasCompletedWideEvents() override suspend fun getWideEvents(ids: Set): List { if (ids.isEmpty()) return emptyList() diff --git a/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/wideevents/ApiWideEventSenderTest.kt b/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/wideevents/ApiWideEventSenderTest.kt new file mode 100644 index 000000000000..791911756868 --- /dev/null +++ b/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/wideevents/ApiWideEventSenderTest.kt @@ -0,0 +1,359 @@ +/* + * 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.wideevents + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.duckduckgo.app.statistics.wideevents.api.AppSection +import com.duckduckgo.app.statistics.wideevents.api.ContextSection +import com.duckduckgo.app.statistics.wideevents.api.FeatureData +import com.duckduckgo.app.statistics.wideevents.api.FeatureSection +import com.duckduckgo.app.statistics.wideevents.api.GlobalSection +import com.duckduckgo.app.statistics.wideevents.api.WideEventRequest +import com.duckduckgo.app.statistics.wideevents.api.WideEventService +import com.duckduckgo.app.statistics.wideevents.db.WideEventRepository +import com.duckduckgo.appbuildconfig.api.AppBuildConfig +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.common.utils.device.DeviceInfo +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.time.Duration +import java.time.Instant + +@RunWith(AndroidJUnit4::class) +class ApiWideEventSenderTest { + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val wideEventService: WideEventService = mock() + + private val appBuildConfig: AppBuildConfig = mock { appBuildConfig -> + whenever(appBuildConfig.versionName).thenReturn("5.123.0") + whenever(appBuildConfig.isDebug).thenReturn(false) + } + + private val deviceInfo: DeviceInfo = mock { deviceInfo -> + whenever(deviceInfo.formFactor()).thenReturn(DeviceInfo.FormFactor.PHONE) + } + + private val apiWideEventSender = ApiWideEventSender( + wideEventService = wideEventService, + appBuildConfig = appBuildConfig, + deviceInfo = deviceInfo, + ) + + @Test + fun `when sendWideEvent called with completed event then sends correct request to service`() = runTest { + val eventName = "subscription-purchase" + + val event = createWideEvent( + id = 123L, + name = eventName, + status = WideEventRepository.WideEventStatus.SUCCESS, + flowEntryPoint = "app_settings", + metadata = mapOf("plan_type" to "premium"), + steps = listOf( + WideEventRepository.WideEventStep(name = "init", success = true), + WideEventRepository.WideEventStep(name = "refresh_data", success = false), + ), + ) + + apiWideEventSender.sendWideEvent(event) + + val expectedRequest = WideEventRequest( + global = GlobalSection( + platform = "Android", + type = "app", + sampleRate = 1, + ), + app = AppSection( + name = "DuckDuckGo Android", + version = "5.123.0", + formFactor = "phone", + devMode = "false", + ), + feature = FeatureSection( + name = eventName, + status = "SUCCESS", + data = FeatureData( + ext = mapOf( + "plan_type" to "premium", + "step.init" to "true", + "step.refresh_data" to "false", + ), + ), + ), + context = ContextSection(name = "app_settings"), + ) + + verify(wideEventService).sendWideEvent(eq(expectedRequest)) + } + + @Test + fun `when sendWideEvent called without flowEntryPoint then context is null`() = runTest { + val event = createWideEvent( + id = 456L, + name = "feature-event", + status = WideEventRepository.WideEventStatus.FAILURE, + flowEntryPoint = null, + ) + + apiWideEventSender.sendWideEvent(event) + + val expectedRequest = WideEventRequest( + global = GlobalSection( + platform = "Android", + type = "app", + sampleRate = 1, + ), + app = AppSection( + name = "DuckDuckGo Android", + version = "5.123.0", + formFactor = "phone", + devMode = "false", + ), + feature = FeatureSection( + name = "feature-event", + status = "FAILURE", + data = null, + ), + context = null, + ) + + verify(wideEventService).sendWideEvent(eq(expectedRequest)) + } + + @Test + fun `when sendWideEvent called without metadata and steps then feature data is null`() = runTest { + val event = createWideEvent( + id = 789L, + name = "simple-event", + status = WideEventRepository.WideEventStatus.CANCELLED, + metadata = emptyMap(), + steps = emptyList(), + ) + + apiWideEventSender.sendWideEvent(event) + + val expectedRequest = WideEventRequest( + global = GlobalSection( + platform = "Android", + type = "app", + sampleRate = 1, + ), + app = AppSection( + name = "DuckDuckGo Android", + version = "5.123.0", + formFactor = "phone", + devMode = "false", + ), + feature = FeatureSection( + name = "simple-event", + status = "CANCELLED", + data = null, + ), + context = null, + ) + + verify(wideEventService).sendWideEvent(eq(expectedRequest)) + } + + @Test + fun `when sendWideEvent called with debug build then devMode is true`() = runTest { + whenever(appBuildConfig.isDebug).thenReturn(true) + + val event = createWideEvent( + id = 100L, + name = "debug-event", + status = WideEventRepository.WideEventStatus.SUCCESS, + ) + + apiWideEventSender.sendWideEvent(event) + + val expectedRequest = WideEventRequest( + global = GlobalSection( + platform = "Android", + type = "app", + sampleRate = 1, + ), + app = AppSection( + name = "DuckDuckGo Android", + version = "5.123.0", + formFactor = "phone", + devMode = "true", + ), + feature = FeatureSection( + name = "debug-event", + status = "SUCCESS", + data = null, + ), + context = null, + ) + + verify(wideEventService).sendWideEvent(eq(expectedRequest)) + } + + @Test + fun `when sendWideEvent called on tablet then formFactor is tablet`() = runTest { + whenever(deviceInfo.formFactor()).thenReturn(DeviceInfo.FormFactor.TABLET) + + val event = createWideEvent( + id = 200L, + name = "tablet-event", + status = WideEventRepository.WideEventStatus.SUCCESS, + ) + + apiWideEventSender.sendWideEvent(event) + + val expectedRequest = WideEventRequest( + global = GlobalSection( + platform = "Android", + type = "app", + sampleRate = 1, + ), + app = AppSection( + name = "DuckDuckGo Android", + version = "5.123.0", + formFactor = "tablet", + devMode = "false", + ), + feature = FeatureSection( + name = "tablet-event", + status = "SUCCESS", + data = null, + ), + context = null, + ) + + verify(wideEventService).sendWideEvent(eq(expectedRequest)) + } + + @Test + fun `when sendWideEvent called with UNKNOWN status then status is UNKNOWN`() = runTest { + val event = createWideEvent( + id = 300L, + name = "unknown-event", + status = WideEventRepository.WideEventStatus.UNKNOWN, + ) + + apiWideEventSender.sendWideEvent(event) + + val expectedRequest = WideEventRequest( + global = GlobalSection( + platform = "Android", + type = "app", + sampleRate = 1, + ), + app = AppSection( + name = "DuckDuckGo Android", + version = "5.123.0", + formFactor = "phone", + devMode = "false", + ), + feature = FeatureSection( + name = "unknown-event", + status = "UNKNOWN", + data = null, + ), + context = null, + ) + + verify(wideEventService).sendWideEvent(eq(expectedRequest)) + } + + @Test + fun `when sendWideEvent called with metadata containing null values then null values are filtered out`() = runTest { + val event = createWideEvent( + id = 400L, + name = "filtered-event", + status = WideEventRepository.WideEventStatus.SUCCESS, + metadata = mapOf( + "key1" to "value1", + "key2" to null, + "key3" to "value3", + ), + ) + + apiWideEventSender.sendWideEvent(event) + + val expectedRequest = WideEventRequest( + global = GlobalSection( + platform = "Android", + type = "app", + sampleRate = 1, + ), + app = AppSection( + name = "DuckDuckGo Android", + version = "5.123.0", + formFactor = "phone", + devMode = "false", + ), + feature = FeatureSection( + name = "filtered-event", + status = "SUCCESS", + data = FeatureData( + ext = mapOf( + "key1" to "value1", + "key3" to "value3", + ), + ), + ), + context = null, + ) + + verify(wideEventService).sendWideEvent(eq(expectedRequest)) + } + + @Test(expected = IllegalArgumentException::class) + fun `when sendWideEvent called with null status then throws exception`() = runTest { + val event = createWideEvent( + id = 500L, + name = "null-status-event", + status = null, + ) + + apiWideEventSender.sendWideEvent(event) + } + + private fun createWideEvent( + id: Long, + name: String, + status: WideEventRepository.WideEventStatus?, + steps: List = emptyList(), + metadata: Map = emptyMap(), + flowEntryPoint: String? = null, + ) = WideEventRepository.WideEvent( + id = id, + name = name, + status = status, + steps = steps, + metadata = metadata, + flowEntryPoint = flowEntryPoint, + activeIntervals = emptyList(), + cleanupPolicy = WideEventRepository.CleanupPolicy.OnTimeout( + duration = Duration.ofHours(1), + status = WideEventRepository.WideEventStatus.UNKNOWN, + metadata = emptyMap(), + ), + createdAt = Instant.parse("2025-12-03T10:15:30.00Z"), + ) +} diff --git a/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/wideevents/CompletedWideEventsProcessorTest.kt b/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/wideevents/CompletedWideEventsProcessorTest.kt new file mode 100644 index 000000000000..3075b29c7a92 --- /dev/null +++ b/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/wideevents/CompletedWideEventsProcessorTest.kt @@ -0,0 +1,209 @@ +/* + * 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.wideevents + +import android.annotation.SuppressLint +import androidx.lifecycle.Lifecycle.State.CREATED +import androidx.lifecycle.Lifecycle.State.INITIALIZED +import androidx.lifecycle.testing.TestLifecycleOwner +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.WorkManager +import com.duckduckgo.app.statistics.wideevents.db.WideEventRepository +import com.duckduckgo.app.statistics.wideevents.db.WideEventRepository.WideEvent +import com.duckduckgo.app.statistics.wideevents.db.WideEventRepository.WideEventStatus +import com.duckduckgo.common.test.CoroutineTestRule +import com.duckduckgo.feature.toggles.api.FakeFeatureToggleFactory +import com.duckduckgo.feature.toggles.api.Toggle.State +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.* +import java.time.Duration +import java.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class CompletedWideEventsProcessorTest { + @get:Rule + val coroutineRule = CoroutineTestRule() + + private val wideEventRepository: WideEventRepository = mock() + private val wideEventSender: WideEventSender = mock() + private val workManager: WorkManager = mock() + private val lifecycleOwner = TestLifecycleOwner(initialState = INITIALIZED) + + private val hasCompletedEventsFlow = MutableSharedFlow() + + @SuppressLint("DenyListedApi") + private val wideEventFeature: WideEventFeature = + FakeFeatureToggleFactory + .create(WideEventFeature::class.java) + .apply { self().setRawStoredState(State(true)) } + + private lateinit var processor: CompletedWideEventsProcessor + + @Before + fun setup() { + whenever(wideEventRepository.hasCompletedWideEvents()).thenReturn(hasCompletedEventsFlow) + + processor = CompletedWideEventsProcessor( + wideEventRepository = wideEventRepository, + wideEventSender = wideEventSender, + appCoroutineScope = coroutineRule.testScope, + wideEventFeature = wideEventFeature, + dispatcherProvider = coroutineRule.testDispatcherProvider, + workManager = workManager, + ) + + lifecycleOwner.lifecycle.addObserver(processor) + } + + @Test + fun `when onCreate and hasCompletedWideEvents emits true then events are processed`() = runTest { + val event = createTestEvent(id = 1L) + whenever(wideEventRepository.getCompletedWideEventIds()).thenReturn(listOf(1L)) + whenever(wideEventRepository.getWideEvents(setOf(1L))).thenReturn(listOf(event)) + + lifecycleOwner.currentState = CREATED + hasCompletedEventsFlow.emit(true) + advanceUntilIdle() + + verify(wideEventSender).sendWideEvent(event) + verify(wideEventRepository).deleteWideEvent(1L) + } + + @Test + fun `when onCreate and hasCompletedWideEvents emits false then events are not processed`() = runTest { + lifecycleOwner.currentState = CREATED + hasCompletedEventsFlow.emit(false) + advanceUntilIdle() + + verify(wideEventRepository, never()).getCompletedWideEventIds() + verify(wideEventSender, never()).sendWideEvent(any()) + } + + @SuppressLint("DenyListedApi") + @Test + fun `when feature is disabled then events are not processed on onCreate`() = runTest { + wideEventFeature.self().setRawStoredState(State(false)) + + lifecycleOwner.currentState = CREATED + hasCompletedEventsFlow.emit(true) + advanceUntilIdle() + + verify(wideEventRepository, never()).getCompletedWideEventIds() + verify(wideEventSender, never()).sendWideEvent(any()) + } + + @Test + fun `when processCompletedWideEvents called directly then events are sent and deleted`() = runTest { + val event1 = createTestEvent(id = 1L) + val event2 = createTestEvent(id = 2L) + whenever(wideEventRepository.getCompletedWideEventIds()).thenReturn(listOf(1L, 2L)) + whenever(wideEventRepository.getWideEvents(setOf(1L, 2L))).thenReturn(listOf(event1, event2)) + + processor.processCompletedWideEvents() + + inOrder(wideEventSender, wideEventRepository) { + verify(wideEventSender).sendWideEvent(event1) + verify(wideEventRepository).deleteWideEvent(1L) + verify(wideEventSender).sendWideEvent(event2) + verify(wideEventRepository).deleteWideEvent(2L) + } + } + + @SuppressLint("DenyListedApi") + @Test + fun `when processCompletedWideEvents and feature is disabled then nothing is processed`() = runTest { + wideEventFeature.self().setRawStoredState(State(false)) + + processor.processCompletedWideEvents() + + verify(wideEventRepository, never()).getCompletedWideEventIds() + verify(wideEventSender, never()).sendWideEvent(any()) + } + + @Test + fun `when processing fails then retry worker is scheduled`() = runTest { + whenever(wideEventRepository.getCompletedWideEventIds()).thenThrow(RuntimeException("Network error")) + + lifecycleOwner.currentState = CREATED + hasCompletedEventsFlow.emit(true) + advanceUntilIdle() + + verify(workManager).enqueueUniqueWork( + eq(CompletedWideEventsProcessor.TAG_WORKER_COMPLETED_WIDE_EVENTS), + any(), + any(), + ) + } + + @Test + fun `when no completed events then sender is not called`() = runTest { + whenever(wideEventRepository.getCompletedWideEventIds()).thenReturn(emptyList()) + + processor.processCompletedWideEvents() + + verify(wideEventSender, never()).sendWideEvent(any()) + verify(wideEventRepository, never()).deleteWideEvent(any()) + } + + @Test + fun `when multiple emissions of hasCompletedWideEvents then each triggers processing`() = runTest { + val event1 = createTestEvent(id = 1L) + val event2 = createTestEvent(id = 2L) + + whenever(wideEventRepository.getCompletedWideEventIds()) + .thenReturn(listOf(1L)) + .thenReturn(listOf(2L)) + whenever(wideEventRepository.getWideEvents(setOf(1L))).thenReturn(listOf(event1)) + whenever(wideEventRepository.getWideEvents(setOf(2L))).thenReturn(listOf(event2)) + + lifecycleOwner.currentState = CREATED + + hasCompletedEventsFlow.emit(true) + advanceUntilIdle() + + hasCompletedEventsFlow.emit(true) + advanceUntilIdle() + + verify(wideEventSender).sendWideEvent(event1) + verify(wideEventSender).sendWideEvent(event2) + } + + private fun createTestEvent(id: Long): WideEvent = + WideEvent( + id = id, + name = "test_event", + status = WideEventStatus.SUCCESS, + steps = emptyList(), + metadata = emptyMap(), + flowEntryPoint = null, + cleanupPolicy = WideEventRepository.CleanupPolicy.OnTimeout( + duration = Duration.ofDays(7), + status = WideEventStatus.UNKNOWN, + metadata = emptyMap(), + ), + activeIntervals = emptyList(), + createdAt = Instant.parse("2025-12-03T10:15:30.00Z"), + ) +} diff --git a/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/wideevents/db/WideEventRepositoryTest.kt b/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/wideevents/db/WideEventRepositoryTest.kt index 0de944baa041..dc57e2935b88 100644 --- a/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/wideevents/db/WideEventRepositoryTest.kt +++ b/statistics/statistics-impl/src/test/java/com/duckduckgo/app/statistics/wideevents/db/WideEventRepositoryTest.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -191,8 +192,8 @@ class WideEventRepositoryTest { metadata = emptyMap(), ) - val completedEventIds = wideEventRepository.getCompletedWideEventIdsFlow().first() - assertTrue(completedEventIds == setOf(eventId1, eventId4)) + val completedEventIds = wideEventRepository.getCompletedWideEventIds() + assertTrue(completedEventIds.toSet() == setOf(eventId1, eventId4)) } @Test @@ -282,7 +283,8 @@ class WideEventRepositoryTest { ) wideEventRepository.deleteWideEvent(completedEventId) - assertTrue(wideEventRepository.getCompletedWideEventIdsFlow().first().isEmpty()) + assertFalse(wideEventRepository.hasCompletedWideEvents().first()) + assertTrue(wideEventRepository.getCompletedWideEventIds().isEmpty()) assertTrue(wideEventRepository.getActiveWideEventIdsByName("test_event") == listOf(activeEventId)) wideEventRepository.deleteWideEvent(activeEventId)