Skip to content

Commit 1c2f8d6

Browse files
committed
sync devices attributed metric
1 parent c806241 commit 1c2f8d6

File tree

9 files changed

+431
-6
lines changed

9 files changed

+431
-6
lines changed

attributed-metrics/attributed-metrics-impl/src/main/java/com/duckduckgo/app/attributed/metrics/AttributedMetricsConfigFeature.kt

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,4 @@ interface AttributedMetricsConfigFeature {
7070

7171
@Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
7272
fun syncDevices(): Toggle
73-
74-
@Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
75-
fun canEmitSyncDevices(): Toggle
7673
}

subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/metrics/SubscriptionStatusAttributedMetricTest.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ class SubscriptionStatusAttributedMetricTest {
5353
private val subscriptionsDataStore: SubscriptionsDataStore = mock()
5454
private val attributedMetricConfig: AttributedMetricConfig = mock()
5555
private val subscriptionToggle = FakeFeatureToggleFactory.create(
56-
FakeSubscriptionMetricsConfigFeature::class.java)
56+
FakeSubscriptionMetricsConfigFeature::class.java,
57+
)
5758
private val lifecycleOwner: LifecycleOwner = mock()
5859

5960
private lateinit var testee: SubscriptionStatusAttributedMetric

sync/sync-impl/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ dependencies {
4545
implementation project(':remote-messaging-api')
4646
implementation project(path: ':autofill-api')
4747
implementation project(path: ':settings-api') // temporary until we release new settings
48+
implementation project(path: ':attributed-metrics-api')
4849

4950
implementation project(path: ':app-build-config-api')
5051
implementation project(path: ':privacy-config-api')

sync/sync-impl/src/main/java/com/duckduckgo/sync/impl/SyncAccountRepository.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import com.duckduckgo.sync.impl.SyncAuthCode.Connect
4141
import com.duckduckgo.sync.impl.SyncAuthCode.Exchange
4242
import com.duckduckgo.sync.impl.SyncAuthCode.Recovery
4343
import com.duckduckgo.sync.impl.SyncAuthCode.Unknown
44+
import com.duckduckgo.sync.impl.metrics.ConnectedDevicesObserver
4445
import com.duckduckgo.sync.impl.pixels.*
4546
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeUrl
4647
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeUrlWrapper
@@ -98,6 +99,7 @@ interface SyncAccountRepository {
9899
@SingleInstanceIn(AppScope::class)
99100
@WorkerThread
100101
class AppSyncAccountRepository @Inject constructor(
102+
private val connectedDevicesObserver: ConnectedDevicesObserver,
101103
private val syncDeviceIds: SyncDeviceIds,
102104
private val nativeLib: SyncLib,
103105
private val syncApi: SyncApi,
@@ -632,11 +634,12 @@ class AppSyncAccountRepository @Inject constructor(
632634
}
633635
}.sortedWith { a, b ->
634636
if (a.thisDevice) -1 else 1
635-
}.also {
637+
}.also { devices ->
636638
connectedDevicesCached.apply {
637639
clear()
638-
addAll(it)
640+
addAll(devices)
639641
}
642+
connectedDevicesObserver.onDevicesUpdated(devices)
640643
},
641644
)
642645
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.sync.impl.metrics
18+
19+
import com.duckduckgo.app.di.AppCoroutineScope
20+
import com.duckduckgo.di.scopes.AppScope
21+
import com.duckduckgo.sync.impl.ConnectedDevice
22+
import com.squareup.anvil.annotations.ContributesBinding
23+
import dagger.SingleInstanceIn
24+
import kotlinx.coroutines.CoroutineScope
25+
import kotlinx.coroutines.flow.MutableStateFlow
26+
import kotlinx.coroutines.flow.StateFlow
27+
import kotlinx.coroutines.flow.asStateFlow
28+
import kotlinx.coroutines.launch
29+
import javax.inject.Inject
30+
31+
interface ConnectedDevicesObserver {
32+
fun onDevicesUpdated(devices: List<ConnectedDevice>)
33+
fun observeConnectedDevicesCount(): StateFlow<Int>
34+
}
35+
36+
@ContributesBinding(AppScope::class)
37+
@SingleInstanceIn(AppScope::class)
38+
class SyncConnectedDevicesObserver @Inject constructor(
39+
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
40+
) : ConnectedDevicesObserver {
41+
42+
private val _connectedDevicesCount = MutableStateFlow(0)
43+
override fun observeConnectedDevicesCount(): StateFlow<Int> = _connectedDevicesCount.asStateFlow()
44+
45+
override fun onDevicesUpdated(devices: List<ConnectedDevice>) {
46+
appCoroutineScope.launch {
47+
_connectedDevicesCount.emit(devices.size)
48+
}
49+
}
50+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.sync.impl.metrics
18+
19+
import androidx.lifecycle.LifecycleOwner
20+
import com.duckduckgo.app.attributed.metrics.api.AttributedMetric
21+
import com.duckduckgo.app.attributed.metrics.api.AttributedMetricClient
22+
import com.duckduckgo.app.attributed.metrics.api.AttributedMetricConfig
23+
import com.duckduckgo.app.attributed.metrics.api.MetricBucket
24+
import com.duckduckgo.app.di.AppCoroutineScope
25+
import com.duckduckgo.app.lifecycle.MainProcessLifecycleObserver
26+
import com.duckduckgo.common.utils.DispatcherProvider
27+
import com.duckduckgo.di.scopes.AppScope
28+
import com.squareup.anvil.annotations.ContributesMultibinding
29+
import dagger.SingleInstanceIn
30+
import kotlinx.coroutines.CoroutineScope
31+
import kotlinx.coroutines.CoroutineStart.LAZY
32+
import kotlinx.coroutines.Deferred
33+
import kotlinx.coroutines.async
34+
import kotlinx.coroutines.launch
35+
import javax.inject.Inject
36+
37+
@ContributesMultibinding(AppScope::class, AttributedMetric::class)
38+
@ContributesMultibinding(AppScope::class, MainProcessLifecycleObserver::class)
39+
@SingleInstanceIn(AppScope::class)
40+
class SyncDevicesAttributeMetric @Inject constructor(
41+
@AppCoroutineScope private val appCoroutineScope: CoroutineScope,
42+
private val dispatcherProvider: DispatcherProvider,
43+
private val attributedMetricClient: AttributedMetricClient,
44+
private val attributedMetricConfig: AttributedMetricConfig,
45+
private val connectedDevicesObserver: ConnectedDevicesObserver,
46+
) : AttributedMetric, MainProcessLifecycleObserver {
47+
48+
companion object {
49+
private const val PIXEL_NAME = "user_synced_device"
50+
private const val FEATURE_TOGGLE_NAME = "syncDevices"
51+
}
52+
53+
private val isEnabled: Deferred<Boolean> = appCoroutineScope.async(start = LAZY) {
54+
getToggle(FEATURE_TOGGLE_NAME)?.isEnabled() ?: false
55+
}
56+
57+
private val bucketConfig: Deferred<MetricBucket> = appCoroutineScope.async(start = LAZY) {
58+
attributedMetricConfig.getBucketConfiguration()[FEATURE_TOGGLE_NAME] ?: MetricBucket(
59+
buckets = listOf(0, 1),
60+
version = 0,
61+
)
62+
}
63+
64+
override fun onCreate(owner: LifecycleOwner) {
65+
appCoroutineScope.launch(dispatcherProvider.io()) {
66+
if (isEnabled.await()) {
67+
connectedDevicesObserver.observeConnectedDevicesCount().collect { deviceCount ->
68+
if (deviceCount > 0) {
69+
attributedMetricClient.emitMetric(this@SyncDevicesAttributeMetric)
70+
}
71+
}
72+
}
73+
}
74+
}
75+
76+
override fun getPixelName(): String = PIXEL_NAME
77+
78+
override suspend fun getMetricParameters(): Map<String, String> {
79+
val connectedDevices = connectedDevicesObserver.observeConnectedDevicesCount().value
80+
return mapOf("device_count" to getBucketValue(connectedDevices).toString())
81+
}
82+
83+
override suspend fun getTag(): String {
84+
val connectedDevices = connectedDevicesObserver.observeConnectedDevicesCount().value
85+
return getBucketValue(connectedDevices).toString()
86+
}
87+
88+
private suspend fun getBucketValue(number: Int): Int {
89+
val buckets = bucketConfig.await().buckets
90+
return buckets.indexOfFirst { bucket -> number <= bucket }.let { index ->
91+
if (index == -1) buckets.size else index
92+
}
93+
}
94+
95+
private suspend fun getToggle(toggleName: String) =
96+
attributedMetricConfig.metricsToggles().firstOrNull { toggle ->
97+
toggle.featureName().name == toggleName
98+
}
99+
}

sync/sync-impl/src/test/java/com/duckduckgo/sync/impl/AppSyncAccountRepositoryTest.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ import com.duckduckgo.sync.impl.AccountErrorCodes.LOGIN_FAILED
7474
import com.duckduckgo.sync.impl.Result.Error
7575
import com.duckduckgo.sync.impl.Result.Success
7676
import com.duckduckgo.sync.impl.SyncAccountRepository.AuthCode
77+
import com.duckduckgo.sync.impl.metrics.ConnectedDevicesObserver
7778
import com.duckduckgo.sync.impl.pixels.SyncPixels
7879
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeUrl
7980
import com.duckduckgo.sync.impl.ui.qrcode.SyncBarcodeUrlWrapper
@@ -108,6 +109,7 @@ class AppSyncAccountRepositoryTest {
108109
private var syncEngine: SyncEngine = mock()
109110
private var syncPixels: SyncPixels = mock()
110111
private val deviceKeyGenerator: DeviceKeyGenerator = mock()
112+
private val connectedDevicesObserver: ConnectedDevicesObserver = mock()
111113
private val moshi = Moshi.Builder().build()
112114
private val invitationCodeWrapperAdapter = moshi.adapter(InvitationCodeWrapper::class.java)
113115
private val invitedDeviceDetailsAdapter = moshi.adapter(InvitedDeviceDetails::class.java)
@@ -123,6 +125,7 @@ class AppSyncAccountRepositoryTest {
123125
@Before
124126
fun before() {
125127
syncRepo = AppSyncAccountRepository(
128+
connectedDevicesObserver,
126129
syncDeviceIds,
127130
nativeLib,
128131
syncApi,
@@ -587,6 +590,19 @@ class AppSyncAccountRepositoryTest {
587590
assertEquals(listOfConnectedDevices, result.data)
588591
}
589592

593+
@Test
594+
fun getConnectedDevicesSucceedsThenNotifyDevicesObserver() {
595+
whenever(syncStore.token).thenReturn(token)
596+
whenever(syncStore.primaryKey).thenReturn(primaryKey)
597+
whenever(syncStore.deviceId).thenReturn(deviceId)
598+
prepareForEncryption()
599+
whenever(syncApi.getDevices(anyString())).thenReturn(getDevicesSuccess)
600+
601+
val result = syncRepo.getConnectedDevices() as Success
602+
603+
verify(connectedDevicesObserver).onDevicesUpdated(any())
604+
}
605+
590606
@Test
591607
fun getConnectedDevicesReturnsListWithLocalDeviceInFirstPosition() {
592608
givenAuthenticatedDevice()
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/*
2+
* Copyright (c) 2025 DuckDuckGo
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.duckduckgo.sync.impl.metrics
18+
19+
import androidx.test.ext.junit.runners.AndroidJUnit4
20+
import app.cash.turbine.test
21+
import com.duckduckgo.common.test.CoroutineTestRule
22+
import com.duckduckgo.sync.TestSyncFixtures.connectedDevice
23+
import kotlinx.coroutines.ExperimentalCoroutinesApi
24+
import kotlinx.coroutines.test.runTest
25+
import org.junit.Assert.assertEquals
26+
import org.junit.Before
27+
import org.junit.Rule
28+
import org.junit.Test
29+
import org.junit.runner.RunWith
30+
31+
@ExperimentalCoroutinesApi
32+
@RunWith(AndroidJUnit4::class)
33+
class SyncConnectedDevicesObserverTest {
34+
35+
@get:Rule
36+
val coroutineRule = CoroutineTestRule()
37+
38+
private lateinit var observer: SyncConnectedDevicesObserver
39+
40+
@Before
41+
fun setup() {
42+
observer = SyncConnectedDevicesObserver(coroutineRule.testScope)
43+
}
44+
45+
@Test
46+
fun whenNoDevicesUpdatedThenEmitsZero() = runTest {
47+
observer.observeConnectedDevicesCount().test {
48+
assertEquals(0, awaitItem())
49+
cancelAndIgnoreRemainingEvents()
50+
}
51+
}
52+
53+
@Test
54+
fun whenDevicesUpdatedThenEmitsCorrectCount() = runTest {
55+
val devices = listOf(
56+
connectedDevice.copy(deviceId = "device1", thisDevice = true),
57+
connectedDevice.copy(deviceId = "device2"),
58+
)
59+
60+
observer.observeConnectedDevicesCount().test {
61+
assertEquals(0, awaitItem())
62+
observer.onDevicesUpdated(devices)
63+
assertEquals(2, awaitItem())
64+
cancelAndIgnoreRemainingEvents()
65+
}
66+
}
67+
68+
@Test
69+
fun whenDevicesUpdatedMultipleTimesThenEmitsLatestCount() = runTest {
70+
val devices1 = listOf(connectedDevice)
71+
72+
val devices2 = listOf(
73+
connectedDevice.copy(deviceId = "device1", thisDevice = true),
74+
connectedDevice.copy(deviceId = "device2"),
75+
connectedDevice.copy(deviceId = "device3"),
76+
)
77+
78+
observer.observeConnectedDevicesCount().test {
79+
assertEquals(0, awaitItem())
80+
81+
observer.onDevicesUpdated(devices1)
82+
assertEquals(1, awaitItem())
83+
84+
observer.onDevicesUpdated(devices2)
85+
assertEquals(3, awaitItem())
86+
87+
cancelAndIgnoreRemainingEvents()
88+
}
89+
}
90+
}

0 commit comments

Comments
 (0)