diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 64d43a759..90a786cb1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -135,6 +135,21 @@ android:name="google_analytics_default_allow_ad_personalization_signals" android:value="false" /> + + + + + + + + + . - */ -package com.geeksville.mesh.widget - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.unmockkAll -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableStateFlow -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.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.database.model.Node -import org.meshtastic.core.model.util.onlineTimeThreshold -import org.meshtastic.core.resources.getStringSuspend -import org.meshtastic.core.service.ConnectionState -import org.meshtastic.core.service.ServiceRepository -import org.meshtastic.proto.DeviceMetrics -import org.meshtastic.proto.LocalStats -import org.meshtastic.proto.User -import org.robolectric.annotation.Config - -@RunWith(AndroidJUnit4::class) -@Config(sdk = [34]) -@OptIn(ExperimentalCoroutinesApi::class) -class LocalStatsWidgetStateProviderTest { - - private val connectionStateFlow = MutableStateFlow(ConnectionState.Disconnected) - private val nodeDbFlow = MutableStateFlow>(emptyMap()) - private val localStatsFlow = MutableStateFlow(LocalStats()) - private val ourNodeInfoFlow = MutableStateFlow(null) - - private val serviceRepository = mockk(relaxed = true) - private val nodeRepository = mockk(relaxed = true) - - @Before - fun setUp() { - mockkStatic("org.meshtastic.core.resources.ContextExtKt") - mockkStatic("org.meshtastic.core.model.util.TimeUtilsKt") - - coEvery { getStringSuspend(any()) } returns "Mock String" - coEvery { getStringSuspend(any(), *anyVararg()) } returns "Mock Formatted String" - every { onlineTimeThreshold() } returns 0 - - // Explicitly return flows from mocks - every { serviceRepository.connectionState } returns connectionStateFlow - every { nodeRepository.nodeDBbyNum } returns nodeDbFlow - every { nodeRepository.localStats } returns localStatsFlow - every { nodeRepository.ourNodeInfo } returns ourNodeInfoFlow - } - - @After - fun tearDown() { - unmockkAll() - } - - @Test - fun `initial state reflects disconnected status`() = runTest { - val provider = LocalStatsWidgetStateProvider(nodeRepository, serviceRepository) - val state = provider.state.first() - assertEquals(ConnectionState.Disconnected, state.connectionState) - assertFalse(state.showContent) - } - - @Test - fun `connected state shows content and maps node info`() = runTest { - connectionStateFlow.value = ConnectionState.Connected - ourNodeInfoFlow.value = - Node( - num = 123, - user = User(short_name = "ABC"), - deviceMetrics = DeviceMetrics(battery_level = 85, channel_utilization = 12.5f), - ) - - val provider = LocalStatsWidgetStateProvider(nodeRepository, serviceRepository) - val state = - provider.state.first { (it.connectionState == ConnectionState.Connected) && (it.nodeShortName == "ABC") } - - assertTrue(state.showContent) - assertEquals("ABC", state.nodeShortName) - assertEquals("85%", state.batteryValue) - } - - @Test - fun `node count and update timestamp are populated`() = runTest { - connectionStateFlow.value = ConnectionState.Connected - nodeDbFlow.value = mapOf(1 to Node(num = 1, lastHeard = 1000)) - - val provider = LocalStatsWidgetStateProvider(nodeRepository, serviceRepository) - val state = provider.state.first { it.nodeCountText == "1/1" } - - assertEquals("1/1", state.nodeCountText) - assertEquals("Mock Formatted String", state.updatedText) - } -} diff --git a/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt b/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt index a9786bd4e..7bd13f840 100644 --- a/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt +++ b/core/analytics/src/google/kotlin/org/meshtastic/core/analytics/platform/GooglePlatformAnalytics.kt @@ -30,7 +30,6 @@ import com.datadog.android.Datadog import com.datadog.android.DatadogSite import com.datadog.android.compose.ExperimentalTrackingApi import com.datadog.android.compose.NavigationViewTrackingEffect -import com.datadog.android.compose.enableComposeActionTracking import com.datadog.android.core.configuration.Configuration import com.datadog.android.log.Logger import com.datadog.android.log.Logs @@ -46,6 +45,8 @@ import com.datadog.android.trace.opentelemetry.DatadogOpenTelemetry import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailabilityLight import com.google.firebase.Firebase +import com.google.firebase.analytics.FirebaseAnalytics.ConsentStatus +import com.google.firebase.analytics.FirebaseAnalytics.ConsentType import com.google.firebase.analytics.analytics import com.google.firebase.crashlytics.crashlytics import com.google.firebase.crashlytics.setCustomKeys @@ -64,16 +65,22 @@ import co.touchlab.kermit.Logger as KermitLogger /** * Google Play Services specific implementation of [PlatformAnalytics]. This helper initializes and manages Firebase and * Datadog services, and subscribes to analytics preference changes to update consent accordingly. + * + * This implementation delays initialization of SDKs until user consent is granted to reduce tracking "noise" and + * respect privacy-focused environments. */ class GooglePlatformAnalytics @Inject constructor( @ApplicationContext private val context: Context, - analyticsPrefs: AnalyticsPrefs, + private val analyticsPrefs: AnalyticsPrefs, ) : PlatformAnalytics { private val sampleRate = 100f.takeIf { BuildConfig.DEBUG } ?: 10f // For Datadog remote sample rate + private var datadogLogger: Logger? = null + private var isFirebaseInitialized = false + private val isInTestLab: Boolean get() { val testLabSetting = Settings.System.getString(context.contentResolver, "firebase.test.lab") @@ -83,22 +90,16 @@ constructor( companion object { private const val TAG = "GooglePlatformAnalytics" private const val SERVICE_NAME = "org.meshtastic" + + private const val KEY_PRIORITY = "priority" + private const val KEY_TAG = "tag" + private const val KEY_MESSAGE = "message" } init { - initDatadog(context as Application) - initCrashlytics(context as Application) - - val datadogLogger = - Logger.Builder() - .setService(SERVICE_NAME) - .setNetworkInfoEnabled(false) // Disable to avoid collecting Local IP/SSID - .setRemoteSampleRate(sampleRate) - .setBundleWithTraceEnabled(true) - .setBundleWithRumEnabled(true) - .build() + // Setup Kermit log writers immediately, they will handle delayed SDK initialization gracefully. val writers = buildList { - add(DatadogLogWriter(datadogLogger)) + add(DatadogLogWriter()) add(CrashlyticsLogWriter()) if (BuildConfig.DEBUG) { add(co.touchlab.kermit.LogcatWriter()) @@ -117,6 +118,30 @@ constructor( .launchIn(ProcessLifecycleOwner.get().lifecycleScope) } + /** + * Ensures that Datadog and Firebase SDKs are initialized if allowed. This is called lazily when consent is granted. + */ + private fun ensureInitialized() { + if (!analyticsPrefs.analyticsAllowed || isInTestLab) return + + if (!Datadog.isInitialized()) { + initDatadog(context as Application) + datadogLogger = + Logger.Builder() + .setService(SERVICE_NAME) + .setNetworkInfoEnabled(false) // Disable to avoid collecting Local IP/SSID + .setRemoteSampleRate(sampleRate) + .setBundleWithTraceEnabled(true) + .setBundleWithRumEnabled(true) + .build() + } + + if (!isFirebaseInitialized) { + initCrashlytics(context as Application) + isFirebaseInitialized = true + } + } + private fun initDatadog(application: Application) { val configuration = Configuration.Builder( @@ -135,13 +160,11 @@ constructor( val rumConfiguration = RumConfiguration.Builder(BuildConfig.datadogApplicationId) .trackAnonymousUser(true) - .trackBackgroundEvents(true) - .trackFrustrations(true) + .trackBackgroundEvents(false) // Disable background noise + .trackFrustrations(false) // Disable click-tracking based frustration detection .trackLongTasks() .trackNonFatalAnrs(true) - .trackUserInteractions() .setSessionSampleRate(sampleRate) - .enableComposeActionTracking() .build() Rum.enable(rumConfiguration) @@ -153,12 +176,24 @@ constructor( GlobalOpenTelemetry.set(DatadogOpenTelemetry(serviceName = SERVICE_NAME)) - // Session Replay disabled to reduce PII collection as requested + // Session Replay disabled to reduce PII collection } private fun initCrashlytics(application: Application) { Firebase.initialize(application) - // User ID tracking disabled to avoid collecting Unique Identifier PII + + // Deny all ad-related consent types by default to minimize tracking noise + Firebase.analytics.setConsent( + mapOf( + ConsentType.AD_STORAGE to ConsentStatus.DENIED, + ConsentType.AD_USER_DATA to ConsentStatus.DENIED, + ConsentType.AD_PERSONALIZATION to ConsentStatus.DENIED, + ConsentType.ANALYTICS_STORAGE to ConsentStatus.DENIED, + ), + ) + + // Explicitly disable analytics collection until we confirm user consent + Firebase.analytics.setAnalyticsCollectionEnabled(false) } /** @@ -167,18 +202,39 @@ constructor( * @param allowed True if analytics are allowed, false otherwise. */ fun updateAnalyticsConsent(allowed: Boolean) { - if (!isPlatformServicesAvailable || isInTestLab) { - KermitLogger.i { "Analytics not available or in test lab, consent update skipped." } - return - } - KermitLogger.i { if (allowed) "Analytics enabled" else "Analytics disabled" } - - Datadog.setTrackingConsent(if (allowed) TrackingConsent.GRANTED else TrackingConsent.NOT_GRANTED) - Firebase.crashlytics.isCrashlyticsCollectionEnabled = allowed - Firebase.analytics.setAnalyticsCollectionEnabled(allowed) + if (isInTestLab) return if (allowed) { - Firebase.crashlytics.sendUnsentReports() + ensureInitialized() + } + + KermitLogger.i { if (allowed) "Analytics enabled" else "Analytics disabled" } + + if (Datadog.isInitialized()) { + Datadog.setTrackingConsent(if (allowed) TrackingConsent.GRANTED else TrackingConsent.NOT_GRANTED) + } + + if (isFirebaseInitialized) { + Firebase.crashlytics.isCrashlyticsCollectionEnabled = allowed + Firebase.analytics.setAnalyticsCollectionEnabled(allowed) + + if (allowed) { + Firebase.crashlytics.sendUnsentReports() + // Ensure ad-related PII collection remains disabled even if analytics is allowed + Firebase.analytics.setUserProperty("allow_personalized_ads", "false") + } + + // Manage Analytics Storage consent for Advanced Consent Mode + val consentStatus = if (allowed) ConsentStatus.GRANTED else ConsentStatus.DENIED + Firebase.analytics.setConsent( + mapOf( + ConsentType.ANALYTICS_STORAGE to consentStatus, + // Keep ad-related types explicitly denied + ConsentType.AD_STORAGE to ConsentStatus.DENIED, + ConsentType.AD_USER_DATA to ConsentStatus.DENIED, + ConsentType.AD_PERSONALIZATION to ConsentStatus.DENIED, + ), + ) } } @@ -206,20 +262,12 @@ constructor( it != ConnectionResult.SERVICE_MISSING && it != ConnectionResult.SERVICE_INVALID } - private val isDatadogAvailable: Boolean - get() = Datadog.isInitialized() - override val isPlatformServicesAvailable: Boolean - get() = isGooglePlayAvailable && isDatadogAvailable - - private class CrashlyticsLogWriter : LogWriter() { - companion object { - private const val KEY_PRIORITY = "priority" - private const val KEY_TAG = "tag" - private const val KEY_MESSAGE = "message" - } + get() = isGooglePlayAvailable + private inner class CrashlyticsLogWriter : LogWriter() { override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) { + if (!isFirebaseInitialized) return if (!Firebase.crashlytics.isCrashlyticsCollectionEnabled) return // Add the log to the Crashlytics log buffer so it appears in reports @@ -244,8 +292,9 @@ constructor( } } - private class DatadogLogWriter(private val datadogLogger: Logger) : LogWriter() { + private inner class DatadogLogWriter : LogWriter() { override fun log(severity: Severity, message: String, tag: String, throwable: Throwable?) { + val logger = datadogLogger ?: return val datadogPriority = when (severity) { Severity.Verbose -> android.util.Log.VERBOSE @@ -255,7 +304,7 @@ constructor( Severity.Error -> android.util.Log.ERROR Severity.Assert -> android.util.Log.ASSERT } - datadogLogger.log(datadogPriority, message, throwable, mapOf("tag" to tag)) + logger.log(datadogPriority, message, throwable, mapOf("tag" to tag)) } } @@ -266,6 +315,7 @@ constructor( } override fun track(event: String, vararg properties: DataPair) { + if (!isFirebaseInitialized) return val bundle = Bundle() properties.forEach { when (it.value) { diff --git a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefs.kt b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefs.kt index f2ed41a8c..bb7592a1e 100644 --- a/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefs.kt +++ b/core/prefs/src/main/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefs.kt @@ -60,7 +60,7 @@ constructor( @AppSharedPreferences appPrefs: SharedPreferences, ) : AnalyticsPrefs { override var analyticsAllowed: Boolean by - PrefDelegate(analyticsSharedPreferences, AnalyticsPrefs.KEY_ANALYTICS_ALLOWED, true) + PrefDelegate(analyticsSharedPreferences, AnalyticsPrefs.KEY_ANALYTICS_ALLOWED, false) private var _installId: String? by NullableStringPrefDelegate(appPrefs, "appPrefs_install_id", null)