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 @@ -70,7 +70,4 @@ interface AttributedMetricsConfigFeature {

@Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
fun syncDevices(): Toggle

@Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
fun canEmitSyncDevices(): Toggle
}
1 change: 1 addition & 0 deletions sync/sync-impl/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ dependencies {
implementation project(':remote-messaging-api')
implementation project(path: ':autofill-api')
implementation project(path: ':settings-api') // temporary until we release new settings
implementation project(path: ':attributed-metrics-api')

implementation project(path: ':app-build-config-api')
implementation project(path: ':privacy-config-api')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import com.duckduckgo.sync.impl.SyncAuthCode.Connect
import com.duckduckgo.sync.impl.SyncAuthCode.Exchange
import com.duckduckgo.sync.impl.SyncAuthCode.Recovery
import com.duckduckgo.sync.impl.SyncAuthCode.Unknown
import com.duckduckgo.sync.impl.metrics.ConnectedDevicesObserver
import com.duckduckgo.sync.impl.pixels.*
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeUrl
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeUrlWrapper
Expand Down Expand Up @@ -98,6 +99,7 @@ interface SyncAccountRepository {
@SingleInstanceIn(AppScope::class)
@WorkerThread
class AppSyncAccountRepository @Inject constructor(
private val connectedDevicesObserver: ConnectedDevicesObserver,
private val syncDeviceIds: SyncDeviceIds,
private val nativeLib: SyncLib,
private val syncApi: SyncApi,
Expand Down Expand Up @@ -632,11 +634,12 @@ class AppSyncAccountRepository @Inject constructor(
}
}.sortedWith { a, b ->
if (a.thisDevice) -1 else 1
}.also {
}.also { devices ->
connectedDevicesCached.apply {
clear()
addAll(it)
addAll(devices)
}
connectedDevicesObserver.onDevicesUpdated(devices)
},
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* 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.sync.impl.metrics

import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.sync.impl.ConnectedDevice
import com.squareup.anvil.annotations.ContributesBinding
import dagger.SingleInstanceIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject

interface ConnectedDevicesObserver {
fun onDevicesUpdated(devices: List<ConnectedDevice>)
fun observeConnectedDevicesCount(): StateFlow<Int>
}

@ContributesBinding(AppScope::class)
@SingleInstanceIn(AppScope::class)
class SyncConnectedDevicesObserver @Inject constructor(
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
) : ConnectedDevicesObserver {

private val _connectedDevicesCount = MutableStateFlow(0)
override fun observeConnectedDevicesCount(): StateFlow<Int> = _connectedDevicesCount.asStateFlow()

override fun onDevicesUpdated(devices: List<ConnectedDevice>) {
appCoroutineScope.launch {
_connectedDevicesCount.emit(devices.size)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* 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.sync.impl.metrics

import androidx.lifecycle.LifecycleOwner
import com.duckduckgo.app.attributed.metrics.api.AttributedMetric
import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient
import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig
import com.duckduckgo.app.attributed.metrics.api.MetricBucket
import com.duckduckgo.app.di.AppCoroutineScope
import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver
import com.duckduckgo.common.utils.DispatcherProvider
import com.duckduckgo.di.scopes.AppScope
import com.squareup.anvil.annotations.ContributesMultibinding
import dagger.SingleInstanceIn
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart.LAZY
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import javax.inject.Inject

@ContributesMultibinding(AppScope::class, AttributedMetric::class)
@ContributesMultibinding(AppScope::class, MainProcessLifecycleObserver::class)
@SingleInstanceIn(AppScope::class)
class SyncDevicesAttributeMetric @Inject constructor(
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
private val dispatcherProvider: DispatcherProvider,
private val attributedMetricClient: AttributedMetricClient,
private val attributedMetricConfig: AttributedMetricConfig,
private val connectedDevicesObserver: ConnectedDevicesObserver,
) : AttributedMetric, MainProcessLifecycleObserver {

companion object {
private const val PIXEL_NAME = "user_synced_device"
private const val FEATURE_TOGGLE_NAME = "syncDevices"
}

private val isEnabled: Deferred<Boolean> = appCoroutineScope.async(start = LAZY) {
getToggle(FEATURE_TOGGLE_NAME)?.isEnabled() ?: false
}

private val bucketConfig: Deferred<MetricBucket> = appCoroutineScope.async(start = LAZY) {
attributedMetricConfig.getBucketConfiguration()[PIXEL_NAME] ?: MetricBucket(
buckets = listOf(1),
version = 0,
)
}

override fun onCreate(owner: LifecycleOwner) {
appCoroutineScope.launch(dispatcherProvider.io()) {
if (isEnabled.await()) {
connectedDevicesObserver.observeConnectedDevicesCount().collect { deviceCount ->
if (deviceCount > 0) {
attributedMetricClient.emitMetric(this@SyncDevicesAttributeMetric)
}
}
}
}
}

override fun getPixelName(): String = PIXEL_NAME

override suspend fun getMetricParameters(): Map<String, String> {
val connectedDevices = connectedDevicesObserver.observeConnectedDevicesCount().value
return mapOf("device_count" to getBucketValue(connectedDevices).toString())
}

override suspend fun getTag(): String {
val connectedDevices = connectedDevicesObserver.observeConnectedDevicesCount().value
return getBucketValue(connectedDevices).toString()
}

private suspend fun getBucketValue(number: Int): Int {
val buckets = bucketConfig.await().buckets
return buckets.indexOfFirst { bucket -> number <= bucket }.let { index ->
if (index == -1) buckets.size else index
}
}

private suspend fun getToggle(toggleName: String) =
attributedMetricConfig.metricsToggles().firstOrNull { toggle ->
toggle.featureName().name == toggleName
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import com.duckduckgo.sync.impl.AccountErrorCodes.LOGIN_FAILED
import com.duckduckgo.sync.impl.Result.Error
import com.duckduckgo.sync.impl.Result.Success
import com.duckduckgo.sync.impl.SyncAccountRepository.AuthCode
import com.duckduckgo.sync.impl.metrics.ConnectedDevicesObserver
import com.duckduckgo.sync.impl.pixels.SyncPixels
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeUrl
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeUrlWrapper
Expand Down Expand Up @@ -108,6 +109,7 @@ class AppSyncAccountRepositoryTest {
private var syncEngine: SyncEngine = mock()
private var syncPixels: SyncPixels = mock()
private val deviceKeyGenerator: DeviceKeyGenerator = mock()
private val connectedDevicesObserver: ConnectedDevicesObserver = mock()
private val moshi = Moshi.Builder().build()
private val invitationCodeWrapperAdapter = moshi.adapter(InvitationCodeWrapper::class.java)
private val invitedDeviceDetailsAdapter = moshi.adapter(InvitedDeviceDetails::class.java)
Expand All @@ -123,6 +125,7 @@ class AppSyncAccountRepositoryTest {
@Before
fun before() {
syncRepo = AppSyncAccountRepository(
connectedDevicesObserver,
syncDeviceIds,
nativeLib,
syncApi,
Expand Down Expand Up @@ -587,6 +590,19 @@ class AppSyncAccountRepositoryTest {
assertEquals(listOfConnectedDevices, result.data)
}

@Test
fun getConnectedDevicesSucceedsThenNotifyDevicesObserver() {
whenever(syncStore.token).thenReturn(token)
whenever(syncStore.primaryKey).thenReturn(primaryKey)
whenever(syncStore.deviceId).thenReturn(deviceId)
prepareForEncryption()
whenever(syncApi.getDevices(anyString())).thenReturn(getDevicesSuccess)

val result = syncRepo.getConnectedDevices() as Success

verify(connectedDevicesObserver).onDevicesUpdated(any())
}

@Test
fun getConnectedDevicesReturnsListWithLocalDeviceInFirstPosition() {
givenAuthenticatedDevice()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/*
* 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.sync.impl.metrics

import androidx.test.ext.junit.runners.AndroidJUnit4
import app.cash.turbine.test
import com.duckduckgo.common.test.CoroutineTestRule
import com.duckduckgo.sync.TestSyncFixtures.connectedDevice
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@ExperimentalCoroutinesApi
@RunWith(AndroidJUnit4::class)
class SyncConnectedDevicesObserverTest {

@get:Rule
val coroutineRule = CoroutineTestRule()

private lateinit var observer: SyncConnectedDevicesObserver

@Before
fun setup() {
observer = SyncConnectedDevicesObserver(coroutineRule.testScope)
}

@Test
fun whenNoDevicesUpdatedThenEmitsZero() = runTest {
observer.observeConnectedDevicesCount().test {
assertEquals(0, awaitItem())
cancelAndIgnoreRemainingEvents()
}
}

@Test
fun whenDevicesUpdatedThenEmitsCorrectCount() = runTest {
val devices = listOf(
connectedDevice.copy(deviceId = "device1", thisDevice = true),
connectedDevice.copy(deviceId = "device2"),
)

observer.observeConnectedDevicesCount().test {
assertEquals(0, awaitItem())
observer.onDevicesUpdated(devices)
assertEquals(2, awaitItem())
cancelAndIgnoreRemainingEvents()
}
}

@Test
fun whenDevicesUpdatedMultipleTimesThenEmitsLatestCount() = runTest {
val devices1 = listOf(connectedDevice)

val devices2 = listOf(
connectedDevice.copy(deviceId = "device1", thisDevice = true),
connectedDevice.copy(deviceId = "device2"),
connectedDevice.copy(deviceId = "device3"),
)

observer.observeConnectedDevicesCount().test {
assertEquals(0, awaitItem())

observer.onDevicesUpdated(devices1)
assertEquals(1, awaitItem())

observer.onDevicesUpdated(devices2)
assertEquals(3, awaitItem())

cancelAndIgnoreRemainingEvents()
}
}
}
Loading
Loading