Skip to content
This repository was archived by the owner on Aug 21, 2025. It is now read-only.
Merged
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
4 changes: 4 additions & 0 deletions analytics/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
alias(libs.plugins.gravatar.android.library)
alias(libs.plugins.ksp)
}

android {
Expand All @@ -10,6 +11,9 @@ dependencies {

implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.core)
implementation(libs.koin.annotations)
ksp(libs.koin.ksp.compiler)
implementation(libs.kotlinx.coroutines)
implementation(libs.automattic.tracks)

testImplementation(libs.junit)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.gravatar.analytics

abstract class Tracker {
abstract var userId: String?
abstract fun trackEvent(event: Event)
abstract fun flush()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.gravatar.analytics

data class TrackerSetupData(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not sure about the name. Any ideas?

Choose a reason for hiding this comment

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

What about TrackingContext or TrackerConfig?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

hmm not sure - do you have a strong preference for any of those?

Choose a reason for hiding this comment

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

I like TrackingContext, but I don't have a strong opinion. Feel free to keep TrackerSetupData if you want.

val trackingState: TrackingState = TrackingState.ENABLED,
val userId: String? = null,
)

enum class TrackingState {
ENABLED,
DISABLED,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.gravatar.analytics

import kotlinx.coroutines.flow.Flow

interface TrackerSetupDataProvider {
fun getTrackerSetupData(): Flow<TrackerSetupData>
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,41 @@ package com.gravatar.analytics.tracks
import com.automattic.android.tracks.TracksClient
import com.gravatar.analytics.Event
import com.gravatar.analytics.Tracker
import com.gravatar.analytics.TrackerSetupDataProvider
import com.gravatar.analytics.TrackingState
import com.gravatar.analytics.asJson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koin.core.annotation.Provided
import java.util.UUID

internal class TracksTracker(private val tracksClient: TracksClient) : Tracker() {
internal class TracksTracker(
@Provided trackerSetupDataProvider: TrackerSetupDataProvider,
@Provided applicationScope: CoroutineScope,
private val tracksClient: TracksClient,
) : Tracker() {

internal companion object {
const val TRACKS_EVENT_NAME_PREFIX = "gravatarandroid_"
}

override var userId: String? = null
init {
trackerSetupDataProvider.getTrackerSetupData()
.onEach {
trackingState = it.trackingState
userId = it.userId
}
.launchIn(applicationScope)
}

private var userId: String? = null
private val anonId: String = generateNewAnonID()
private var trackingState: TrackingState = TrackingState.ENABLED

override fun trackEvent(event: Event) {
if (trackingState == TrackingState.DISABLED) return

val userType = userId?.let {
TracksClient.NosaraUserType.WPCOM
} ?: TracksClient.NosaraUserType.ANON
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,34 @@ package com.gravatar.analytics.tracks

import com.automattic.android.tracks.TracksClient
import com.gravatar.analytics.Event
import com.gravatar.analytics.TrackerSetupData
import com.gravatar.analytics.TrackerSetupDataProvider
import io.mockk.mockk
import io.mockk.verify
import io.mockk.verifySequence
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Before
import org.junit.Test

class TrackTrackerTest {

private lateinit var tracker: TracksTracker
private lateinit var mockClient: TracksClient
private lateinit var applicationScope: CoroutineScope
private lateinit var mutableSetupData: MutableStateFlow<TrackerSetupData>

@Before
fun setUp() {
mockClient = mockk<TracksClient>(relaxed = true)
tracker = TracksTracker(mockClient)
applicationScope = CoroutineScope(Dispatchers.Unconfined)
mutableSetupData = MutableStateFlow(TrackerSetupData())
val provider: TrackerSetupDataProvider = object : TrackerSetupDataProvider {
override fun getTrackerSetupData(): Flow<TrackerSetupData> = mutableSetupData
}
tracker = TracksTracker(provider, applicationScope, mockClient)
}

@Test
Expand All @@ -42,7 +55,8 @@ class TrackTrackerTest {
val event = object : Event {
override val name: String = "test_event_with_user"
}
tracker.userId = "someUserId"
// Update the shared state to include a userId so the tracker switches to WPCOM
mutableSetupData.value = TrackerSetupData(userId = "someUserId")

tracker.trackEvent(event)

Expand All @@ -64,4 +78,26 @@ class TrackTrackerTest {
mockClient.flush()
}
}

@Test
fun `when TrackingState changes then trackEvent behavior updates accordingly`() {
val event = object : Event {
override val name: String = "test_event_tracking_state"
}

// Initially ENABLED by default, should track
tracker.trackEvent(event)
verify(exactly = 1) { mockClient.track(any(), any(), any(), any()) }

// Disable tracking: should not track further events
mutableSetupData.value = TrackerSetupData(trackingState = com.gravatar.analytics.TrackingState.DISABLED)
tracker.trackEvent(event)
// Still only one call so far
verify(exactly = 1) { mockClient.track(any(), any(), any(), any()) }

// Re-enable tracking: should resume tracking
mutableSetupData.value = TrackerSetupData(trackingState = com.gravatar.analytics.TrackingState.ENABLED)
tracker.trackEvent(event)
verify(exactly = 2) { mockClient.track(any(), any(), any(), any()) }
}
}
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,8 @@ dependencies {

testImplementation(libs.junit)
testImplementation(libs.koin.test.junit4)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk.android)
testImplementation(libs.turbine)
testImplementation(project(":testUtils"))
}
31 changes: 31 additions & 0 deletions app/src/main/java/com/gravatar/app/AppTrackerSetupDataProvider.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.gravatar.app

import com.gravatar.analytics.TrackerSetupData
import com.gravatar.analytics.TrackerSetupDataProvider
import com.gravatar.analytics.TrackingState
import com.gravatar.app.usercomponent.domain.repository.UserRepository
import com.gravatar.app.usercomponent.domain.usecase.GetPrivacySettings
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map

class AppTrackerSetupDataProvider(
private val getPrivacySettings: GetPrivacySettings,
private val userRepository: UserRepository,
) : TrackerSetupDataProvider {
override fun getTrackerSetupData(): Flow<TrackerSetupData> {
val userIdFlow: Flow<String?> = userRepository.getProfile().map { it?.userLogin }.distinctUntilChanged()
return getPrivacySettings()
.combine(userIdFlow) { privacySettings, userId ->
TrackerSetupData(
trackingState = if (privacySettings.analyticsEnabled) {
TrackingState.ENABLED
} else {
TrackingState.DISABLED
},
userId = userId
)
}
}
}
10 changes: 0 additions & 10 deletions app/src/main/java/com/gravatar/app/analytics/AppEvent.kt

This file was deleted.

5 changes: 5 additions & 0 deletions app/src/main/java/com/gravatar/app/di/AppModule.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package com.gravatar.app.di

import com.gravatar.analytics.TrackerSetupDataProvider
import com.gravatar.analytics.di.analyticsModule
import com.gravatar.app.AppTrackerSetupDataProvider
import com.gravatar.app.clock.di.clockModule
import com.gravatar.app.homeUi.di.homeUiModule
import com.gravatar.app.loginUi.di.loginUiModule
import com.gravatar.app.networkmonitor.di.networkMonitorModule
import com.gravatar.crashlogging.di.crashLoggingModule
import org.koin.core.module.dsl.bind
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module

val appModule = module {
Expand All @@ -19,4 +23,5 @@ val appModule = module {
buildConfigModule,
crashLoggingModule,
)
singleOf(::AppTrackerSetupDataProvider) { bind<TrackerSetupDataProvider>() }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package com.gravatar.app

import app.cash.turbine.test
import com.gravatar.analytics.TrackerSetupData
import com.gravatar.analytics.TrackingState
import com.gravatar.app.testUtils.CoroutineTestRule
import com.gravatar.app.usercomponent.domain.model.PrivacySettings
import com.gravatar.app.usercomponent.domain.repository.UserRepository
import com.gravatar.app.usercomponent.domain.usecase.GetPrivacySettings
import com.gravatar.restapi.models.Profile
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import java.net.URI

@OptIn(ExperimentalCoroutinesApi::class)
class AppTrackerSetupDataProviderTest {
private val testDispatcher = StandardTestDispatcher()

@get:Rule
var coroutineTestRule = CoroutineTestRule(testDispatcher)

private lateinit var provider: AppTrackerSetupDataProvider

private lateinit var privacySettingsFlow: MutableSharedFlow<PrivacySettings>
private lateinit var profileFlow: MutableSharedFlow<Profile?>

private val getPrivacySettings: GetPrivacySettings = object : GetPrivacySettings {
override fun invoke() = privacySettingsFlow
}

private val userRepository: UserRepository = object : UserRepository {
override suspend fun refreshProfile(): Result<Unit> = throw NotImplementedError()
override suspend fun selectAvatar(avatarId: String): Result<Unit> = throw NotImplementedError()
override suspend fun getAvatars() = throw NotImplementedError()
override fun getProfile(): Flow<Profile?> = profileFlow
override suspend fun updateProfile(
updateRequest: com.gravatar.restapi.models.UpdateProfileRequest
): Result<Unit> =
throw NotImplementedError()

override suspend fun uploadAvatar(avatarFile: java.io.File) = throw NotImplementedError()
override suspend fun deleteAvatar(avatarId: String): Result<Unit> = throw NotImplementedError()
}

@Before
fun setup() {
privacySettingsFlow = MutableSharedFlow()
profileFlow = MutableSharedFlow()
provider = AppTrackerSetupDataProvider(
getPrivacySettings = getPrivacySettings,
userRepository = userRepository,
)
}

@Test
fun `emits ENABLED when analytics enabled and non-null user id`() = runTest {
// Given
val profile = createProfile("user")

provider.getTrackerSetupData().test {
// When: emit both flows (combine requires both)
privacySettingsFlow.emit(PrivacySettings(analyticsEnabled = true, crashReportingEnabled = true))
profileFlow.emit(profile)

// Then
assertEquals(TrackerSetupData(TrackingState.ENABLED, "user"), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}

@Test
fun `emits DISABLED when analytics disabled and null user Id when profile is null`() = runTest {
provider.getTrackerSetupData().test {
// When
privacySettingsFlow.emit(PrivacySettings(analyticsEnabled = false, crashReportingEnabled = true))
profileFlow.emit(null)

// Then
assertEquals(TrackerSetupData(TrackingState.DISABLED, null), awaitItem())
cancelAndIgnoreRemainingEvents()
}
}

@Test
fun `updates when privacy settings or user id changes and skips duplicate user id`() = runTest {
// Given initial emissions
val user1 = "user1"
val user2 = "user2"
val profile = createProfile(user1)

provider.getTrackerSetupData().test {
privacySettingsFlow.emit(PrivacySettings(analyticsEnabled = true, crashReportingEnabled = true))
profileFlow.emit(profile)

// First combined emission
assertEquals(
TrackerSetupData(trackingState = TrackingState.ENABLED, userId = user1),
awaitItem()
)

// When: change privacy to disabled => new emission expected
privacySettingsFlow.emit(PrivacySettings(analyticsEnabled = false, crashReportingEnabled = true))
assertEquals(
TrackerSetupData(trackingState = TrackingState.DISABLED, userId = user1),
awaitItem()
)

// When: emit profile with same userId (1) => due to distinctUntilChanged on userIdFlow, no new emission from combine
profileFlow.emit(profile)
expectNoEvents()

// When: emit profile with new userId (2) => new emission expected
val profile2 = createProfile(user2)
profileFlow.emit(profile2)
assertEquals(
TrackerSetupData(trackingState = TrackingState.DISABLED, userId = user2),
awaitItem()
)

cancelAndIgnoreRemainingEvents()
}
}

private fun createProfile(user: String): Profile {
return Profile {
firstName = "John"
lastName = "Doe"
displayName = "Johny"
hash = "1234567890abcdef1234567890abcdef"
location = "New York, USA"
jobTitle = "Software Engineer"
company = "Acme Inc."
description = "A passionate software engineer with a love for coding and technology."
verifiedAccounts = emptyList()
profileUrl = URI.create("https://johndoe.com")
avatarUrl = URI.create("https://www.gravatar.com/avatar/123")
avatarAltText = "John Doe's Gravatar"
pronouns = "he/him"
pronunciation = "John Doe"
verifiedAccounts = emptyList()
Copy link

Copilot AI Aug 20, 2025

Choose a reason for hiding this comment

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

The verifiedAccounts property is set twice in this profile builder (lines 139 and 145). Remove the duplicate assignment.

Suggested change
verifiedAccounts = emptyList()

Copilot uses AI. Check for mistakes.
userLogin = user
}
}
}
Loading