diff --git a/AGENTS.md b/AGENTS.md index 8cce20006..b68b7a3b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ Meshtastic-Android is a Kotlin Multiplatform (KMP) application for off-grid, dec - **Target SDK:** API 36. Min SDK: API 26 (Android 8.0). - **Flavors:** - `fdroid`: Open source only, no tracking/analytics. - - `google`: Includes Google Play Services (Maps) and DataDog analytics. + - `google`: Includes Google Play Services (Maps) and DataDog analytics (RUM, Session Replay, Compose action tracking, custom `connect` RUM action). 100% sampling, Apple-parity environments ("Local"/"Production"). - **Core Architecture:** Modern Android Development (MAD) with KMP core. - **KMP Modules:** Most `core:*` modules. All declare `jvm()`, `iosArm64()`, and `iosSimulatorArm64()` targets and compile clean across all. - **Android-only Modules:** `core:api` (AIDL), `core:barcode` (CameraX + flavor-specific decoder). Shared contracts abstracted into `core:ui/commonMain`. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e248ec629..074b58df5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -285,6 +285,8 @@ dependencies { googleImplementation(libs.dd.sdk.android.compose) googleImplementation(libs.dd.sdk.android.logs) googleImplementation(libs.dd.sdk.android.rum) + googleImplementation(libs.dd.sdk.android.session.replay) + googleImplementation(libs.dd.sdk.android.session.replay.material) googleImplementation(libs.dd.sdk.android.timber) googleImplementation(libs.dd.sdk.android.trace) googleImplementation(libs.dd.sdk.android.trace.otel) diff --git a/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt b/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt index a41eae2d3..691874782 100644 --- a/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt +++ b/app/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt @@ -26,6 +26,7 @@ import co.touchlab.kermit.LogWriter import co.touchlab.kermit.Severity import com.datadog.android.Datadog import com.datadog.android.DatadogSite +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 @@ -33,7 +34,11 @@ import com.datadog.android.log.LogsConfiguration import com.datadog.android.privacy.TrackingConsent import com.datadog.android.rum.GlobalRumMonitor import com.datadog.android.rum.Rum +import com.datadog.android.rum.RumActionType import com.datadog.android.rum.RumConfiguration +import com.datadog.android.sessionreplay.SessionReplay +import com.datadog.android.sessionreplay.SessionReplayConfiguration +import com.datadog.android.sessionreplay.SessionReplayPrivacy import com.datadog.android.trace.Trace import com.datadog.android.trace.TraceConfiguration import com.datadog.android.trace.opentelemetry.DatadogOpenTelemetry @@ -68,7 +73,7 @@ import co.touchlab.kermit.Logger as KermitLogger class GooglePlatformAnalytics(private val context: Context, private val analyticsPrefs: AnalyticsPrefs) : PlatformAnalytics { - private val sampleRate = 100f.takeIf { BuildConfig.DEBUG } ?: 10f // For Datadog remote sample rate + private val sampleRate = 100f // Match Apple: 100% sampling for cross-platform DataDog comparison private var datadogLogger: Logger? = null private var isFirebaseInitialized = false @@ -137,7 +142,7 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic val configuration = Configuration.Builder( clientToken = BuildConfig.datadogClientToken, - env = if (BuildConfig.DEBUG) "debug" else "release", + env = if (BuildConfig.DEBUG) "Local" else "Production", variant = BuildConfig.FLAVOR, ) .useSite(DatadogSite.US5) @@ -151,10 +156,11 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic val rumConfiguration = RumConfiguration.Builder(BuildConfig.datadogApplicationId) .trackAnonymousUser(true) - .trackBackgroundEvents(false) // Disable background noise + .trackBackgroundEvents(true) // Match Apple: track background events for cross-platform parity .trackFrustrations(false) // Disable click-tracking based frustration detection .trackLongTasks() .trackNonFatalAnrs(true) + .enableComposeActionTracking() // Required: activates runtime consumption of Compose semantics tags .setSessionSampleRate(sampleRate) .build() Rum.enable(rumConfiguration) @@ -162,9 +168,17 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic val logsConfig = LogsConfiguration.Builder().build() Logs.enable(logsConfig) - val traceConfig = TraceConfiguration.Builder().setNetworkInfoEnabled(false).build() + val traceConfig = TraceConfiguration.Builder().setNetworkInfoEnabled(true).build() Trace.enable(traceConfig) + // Session Replay for debug builds only, matching Apple's TestFlight-only gating. + // Masks all text inputs to protect message content. + if (BuildConfig.DEBUG) { + val sessionReplayConfig = + SessionReplayConfiguration.Builder(sampleRate).setPrivacy(SessionReplayPrivacy.MASK_USER_INPUT).build() + SessionReplay.enable(sessionReplayConfig) + } + GlobalOpenTelemetry.set(DatadogOpenTelemetry(serviceName = SERVICE_NAME)) } @@ -233,6 +247,24 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic GlobalRumMonitor.get().addAttribute("device_hardware", model) } + override fun trackConnect( + firmwareVersion: String?, + transportType: String?, + hardwareModel: String?, + nodes: Int, + connectionRestored: Boolean, + ) { + if (!Datadog.isInitialized() || !GlobalRumMonitor.isRegistered()) return + val attributes = buildMap { + firmwareVersion?.let { put("firmwareVersion", it) } + transportType?.let { put("transportType", it) } + hardwareModel?.let { put("hardwareModel", it) } + put("nodes", nodes) + if (connectionRestored) put("connectionRestored", true) + } + GlobalRumMonitor.get().addAction(RumActionType.CUSTOM, "connect", attributes) + } + private val isGooglePlayAvailable: Boolean get() = GoogleApiAvailabilityLight.getInstance().isGooglePlayServicesAvailable(context).let { diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt index cb811a96d..bd0cafa4c 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConnectionManagerImpl.kt @@ -33,6 +33,7 @@ import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.ConnectionState +import org.meshtastic.core.model.DeviceType import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.repository.AppWidgetUpdater import org.meshtastic.core.repository.CommandSender @@ -88,6 +89,7 @@ class MeshConnectionManagerImpl( private var locationRequestsJob: Job? = null private var handshakeTimeout: Job? = null private var connectTimeMsec = 0L + private var connectionRestored = false @OptIn(FlowPreview::class) override fun start(scope: CoroutineScope) { @@ -169,6 +171,9 @@ class MeshConnectionManagerImpl( } private fun handleConnected() { + // Track whether this connection was restored from device sleep (vs. a fresh connect), + // matching Apple's "connectionRestored" attribute for cross-platform DataDog parity. + connectionRestored = serviceRepository.connectionState.value is ConnectionState.DeviceSleep // The service state remains 'Connecting' until config is fully loaded if (serviceRepository.connectionState.value != ConnectionState.Connected) { serviceRepository.setConnectionState(ConnectionState.Connecting) @@ -181,22 +186,25 @@ class MeshConnectionManagerImpl( private fun startHandshakeStallGuard(stage: Int, action: () -> Unit) { handshakeTimeout?.cancel() - handshakeTimeout = scope.handledLaunch { - delay(HANDSHAKE_TIMEOUT) - if (serviceRepository.connectionState.value is ConnectionState.Connecting) { - // Attempt one retry. Note: the firmware silently drops identical consecutive - // writes (per-connection dedup). If the first want_config_id was received and - // the stall is on our side, the retry will be dropped and the reconnect below - // will trigger instead — which is the right recovery in that case. - Logger.w { "Handshake stall detected at Stage $stage — retrying, then reconnecting if still stalled." } - action() - delay(HANDSHAKE_RETRY_TIMEOUT) + handshakeTimeout = + scope.handledLaunch { + delay(HANDSHAKE_TIMEOUT) if (serviceRepository.connectionState.value is ConnectionState.Connecting) { - Logger.e { "Handshake still stalled after retry. Forcing reconnect." } - onConnectionChanged(ConnectionState.Disconnected) + // Attempt one retry. Note: the firmware silently drops identical consecutive + // writes (per-connection dedup). If the first want_config_id was received and + // the stall is on our side, the retry will be dropped and the reconnect below + // will trigger instead — which is the right recovery in that case. + Logger.w { + "Handshake stall detected at Stage $stage — retrying, then reconnecting if still stalled." + } + action() + delay(HANDSHAKE_RETRY_TIMEOUT) + if (serviceRepository.connectionState.value is ConnectionState.Connecting) { + Logger.e { "Handshake still stalled after retry. Forcing reconnect." } + onConnectionChanged(ConnectionState.Disconnected) + } } } - } } private fun handleDeviceSleep() { @@ -215,18 +223,19 @@ class MeshConnectionManagerImpl( ) } - sleepTimeout = scope.handledLaunch { - try { - val localConfig = radioConfigRepository.localConfigFlow.first() - val timeout = (localConfig.power?.ls_secs ?: 0) + DEVICE_SLEEP_TIMEOUT_SECONDS - Logger.d { "Waiting for sleeping device, timeout=$timeout secs" } - delay(timeout.seconds) - Logger.w { "Device timeout out, setting disconnected" } - onConnectionChanged(ConnectionState.Disconnected) - } catch (_: CancellationException) { - Logger.d { "device sleep timeout cancelled" } + sleepTimeout = + scope.handledLaunch { + try { + val localConfig = radioConfigRepository.localConfigFlow.first() + val timeout = (localConfig.power?.ls_secs ?: 0) + DEVICE_SLEEP_TIMEOUT_SECONDS + Logger.d { "Waiting for sleeping device, timeout=$timeout secs" } + delay(timeout.seconds) + Logger.w { "Device timeout out, setting disconnected" } + onConnectionChanged(ConnectionState.Disconnected) + } catch (_: CancellationException) { + Logger.d { "device sleep timeout cancelled" } + } } - } serviceBroadcasts.broadcastConnection() } @@ -322,6 +331,16 @@ class MeshConnectionManagerImpl( DataPair(KEY_NUM_ONLINE, nodeManager.nodeDBbyNodeNum.values.count { it.isOnline }), radioModel, ) + + // DataDog RUM custom action matching Apple's "connect" event for cross-platform analytics. + val transportType = radioInterfaceService.getDeviceAddress()?.let { DeviceType.fromAddress(it)?.name } + analytics.trackConnect( + firmwareVersion = myNode?.firmwareVersion, + transportType = transportType, + hardwareModel = myNode?.model, + nodes = nodeManager.nodeDBbyNodeNum.size, + connectionRestored = connectionRestored, + ) } override fun updateTelemetry(t: Telemetry) { diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefsImpl.kt index 8d52c4c0b..dc1143932 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefsImpl.kt @@ -44,8 +44,8 @@ class AnalyticsPrefsImpl( override val analyticsAllowed: StateFlow = analyticsDataStore.data - .map { it[KEY_ANALYTICS_ALLOWED_PREF] ?: false } - .stateIn(scope, SharingStarted.Eagerly, false) + .map { it[KEY_ANALYTICS_ALLOWED_PREF] ?: true } + .stateIn(scope, SharingStarted.Eagerly, true) override fun setAnalyticsAllowed(allowed: Boolean) { scope.launch { analyticsDataStore.edit { prefs -> prefs[KEY_ANALYTICS_ALLOWED_PREF] = allowed } } diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PlatformAnalytics.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PlatformAnalytics.kt index b4ce22165..a8b27c84b 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PlatformAnalytics.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/PlatformAnalytics.kt @@ -32,6 +32,26 @@ interface PlatformAnalytics { */ fun setDeviceAttributes(firmwareVersion: String, model: String) + /** + * Tracks a successful device connection as a custom RUM action, aligned with the Meshtastic-Apple DataDog + * integration for cross-platform analytics comparison. + * + * @param firmwareVersion The firmware version of the connected device (major.minor). + * @param transportType The transport used for the connection (e.g., "BLE", "TCP", "USB"). + * @param hardwareModel The hardware model name of the connected device. + * @param nodes The total number of nodes in the mesh network. + * @param connectionRestored True if this connection was restored from device sleep rather than a fresh connect. + */ + fun trackConnect( + firmwareVersion: String?, + transportType: String?, + hardwareModel: String?, + nodes: Int, + connectionRestored: Boolean, + ) { + // Default no-op for platforms that don't support RUM (fdroid, desktop) + } + /** * Indicates whether platform-specific services (like Google Play Services or Datadog) are available and * initialized. diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt index f96e9f742..2b9f9918f 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeAppPreferences.kt @@ -31,7 +31,7 @@ import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.UiPrefs class FakeAnalyticsPrefs : AnalyticsPrefs { - override val analyticsAllowed = MutableStateFlow(false) + override val analyticsAllowed = MutableStateFlow(true) override fun setAnalyticsAllowed(allowed: Boolean) { analyticsAllowed.value = allowed diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index 7026f981e..1f759eb5b 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -211,7 +211,7 @@ fun SettingsScreen( if (state.isLocal) { PrivacySection( analyticsAvailable = state.analyticsAvailable, - analyticsEnabled = viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(false).value, + analyticsEnabled = viewModel.analyticsAllowedFlow.collectAsStateWithLifecycle(true).value, onToggleAnalytics = { viewModel.toggleAnalyticsAllowed() }, provideLocation = settingsViewModel.provideLocation.collectAsStateWithLifecycle().value, onToggleLocation = { settingsViewModel.setProvideLocation(it) }, diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt index 037717143..dadc165dd 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt @@ -98,7 +98,7 @@ data class RadioConfigState( val fileManifest: List = emptyList(), val responseState: ResponseState = ResponseState.Empty, val analyticsAvailable: Boolean = true, - val analyticsEnabled: Boolean = false, + val analyticsEnabled: Boolean = true, val nodeDbResetPreserveFavorites: Boolean = false, ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0a8f4fc6c..96fa6e955 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -215,6 +215,8 @@ coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } dd-sdk-android-compose = { module = "com.datadoghq:dd-sdk-android-compose", version.ref = "dd-sdk-android" } dd-sdk-android-logs = { module = "com.datadoghq:dd-sdk-android-logs", version.ref = "dd-sdk-android" } dd-sdk-android-rum = { module = "com.datadoghq:dd-sdk-android-rum", version.ref = "dd-sdk-android" } +dd-sdk-android-session-replay = { module = "com.datadoghq:dd-sdk-android-session-replay", version.ref = "dd-sdk-android" } +dd-sdk-android-session-replay-material = { module = "com.datadoghq:dd-sdk-android-session-replay-material", version.ref = "dd-sdk-android" } dd-sdk-android-timber = { module = "com.datadoghq:dd-sdk-android-timber", version.ref = "dd-sdk-android" } dd-sdk-android-trace = { module = "com.datadoghq:dd-sdk-android-trace", version.ref = "dd-sdk-android" } dd-sdk-android-trace-otel = { module = "com.datadoghq:dd-sdk-android-trace-otel", version.ref = "dd-sdk-android" }