diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt b/androidApp/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt index 32989f3f5c..3ac771cb31 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/analytics/GooglePlatformAnalytics.kt @@ -275,6 +275,11 @@ class GooglePlatformAnalytics(private val context: Context, private val analytic GlobalRumMonitor.get().addAction(RumActionType.CUSTOM, "connect", attributes) } + override fun trackAction(name: String, attributes: Map) { + if (!Datadog.isInitialized() || !GlobalRumMonitor.isRegistered()) return + GlobalRumMonitor.get().addAction(RumActionType.CUSTOM, name, attributes) + } + override fun startScreenView(key: String, name: String) { if (!Datadog.isInitialized() || !GlobalRumMonitor.isRegistered()) return GlobalRumMonitor.get().startView(key = key, name = name) 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 9f82630d60..733c70c7a5 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 @@ -52,6 +52,17 @@ interface PlatformAnalytics { // Default no-op for platforms that don't support RUM (fdroid, desktop) } + /** + * Tracks a key user interaction as a named custom RUM action. Auto-tracked tap targets are R8-obfuscated in release + * builds, so analytically important interactions must be reported explicitly with a stable name. + * + * @param name Stable snake_case action name (e.g. "message_send", "traceroute_request"). + * @param attributes Optional structured attributes attached to the action. + */ + fun trackAction(name: String, attributes: Map = emptyMap()) { + // Default no-op for platforms that don't support RUM (fdroid, desktop) + } + /** * Starts tracking a screen as a RUM view, aligned with the Meshtastic-Apple Datadog integration (which auto-tracks * SwiftUI views) so per-screen RUM data lines up across platforms. diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt index d6fcb69efb..0d5c43d388 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/MessagingControllerImpl.kt @@ -62,6 +62,13 @@ internal class MessagingControllerImpl( dataHandler.value.rememberDataPacket(packet, myNodeNum, false) val bytes = packet.bytes ?: ByteString.EMPTY analytics.track("data_send", DataPair("num_bytes", bytes.size), DataPair("type", packet.dataType)) + val actionName = + when (packet.dataType) { + PortNum.TEXT_MESSAGE_APP.value -> "message_send" + PortNum.WAYPOINT_APP.value -> "waypoint_send" + else -> "data_send" + } + analytics.trackAction(actionName, mapOf("port_num" to packet.dataType, "num_bytes" to bytes.size)) } override suspend fun sendReaction(emoji: String, replyId: Int, contactKey: String) { @@ -81,6 +88,7 @@ internal class MessagingControllerImpl( ) .apply { from = nodeManager.getMyId().takeIf { it.isNotEmpty() } ?: NodeAddress.ID_LOCAL } commandSender.sendData(dataPacket) + analytics.trackAction("reaction_send") val user = nodeManager.nodeDBbyNodeNum[myNum]?.user ?: User(id = nodeManager.getMyId()) packetRepository.value.insertReaction( Reaction( diff --git a/feature/firmware/build.gradle.kts b/feature/firmware/build.gradle.kts index 85fa8eab2f..e04f185c02 100644 --- a/feature/firmware/build.gradle.kts +++ b/feature/firmware/build.gradle.kts @@ -38,6 +38,7 @@ kotlin { implementation(projects.core.navigation) implementation(projects.core.network) implementation(projects.core.prefs) + implementation(projects.core.repository) implementation(libs.meshtastic.protobufs) implementation(projects.core.service) implementation(projects.core.resources) diff --git a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt index 79d99dd204..3a8672d6b3 100644 --- a/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt +++ b/feature/firmware/src/commonMain/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModel.kt @@ -55,6 +55,7 @@ import org.meshtastic.core.model.util.anonymize import org.meshtastic.core.repository.DeviceHardwareRepository import org.meshtastic.core.repository.FirmwareReleaseRepository import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioController import org.meshtastic.core.repository.RadioPrefs import org.meshtastic.core.repository.isBle @@ -113,6 +114,7 @@ class FirmwareUpdateViewModel( private val fileHandler: FirmwareFileHandler, private val applicationScope: ApplicationCoroutineScope, private val hiddenFeaturesUnlock: HiddenFeaturesUnlock, + private val analytics: PlatformAnalytics, ) : ViewModel() { private val _state = MutableStateFlow(FirmwareUpdateState.Idle) @@ -321,6 +323,22 @@ class FirmwareUpdateViewModel( fun startUpdate() { val currentState = _state.value as? FirmwareUpdateState.Ready ?: return val release = currentState.release ?: return + // Explicit mapping instead of class-name reflection: FirmwareUpdateMethod is R8-obfuscated in release. + val updateMethod = + when (currentState.updateMethod) { + FirmwareUpdateMethod.Usb -> "usb" + FirmwareUpdateMethod.Ble -> "ble" + FirmwareUpdateMethod.Wifi -> "wifi" + FirmwareUpdateMethod.Unknown -> "unknown" + } + analytics.trackAction( + "firmware_update_start", + mapOf( + "update_method" to updateMethod, + "is_recovery" to currentState.isRecovery, + "release_version" to release.id, + ), + ) if (currentState.isRecovery) { startRecoveryUpdate(currentState, release) } else { diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt index 483cc7bb07..df60d59a70 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateIntegrationTest.kt @@ -115,6 +115,7 @@ class FirmwareUpdateIntegrationTest { fileHandler, TestApplicationCoroutineScope(testDispatcher), HiddenFeaturesUnlock(), + mock(MockMode.autofill), ) @Test diff --git a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt index 52f34a1b9a..ef6581810f 100644 --- a/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt +++ b/feature/firmware/src/commonTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelTest.kt @@ -138,6 +138,7 @@ class FirmwareUpdateViewModelTest { fileHandler, TestApplicationCoroutineScope(testDispatcher), hiddenFeaturesUnlock, + mock(MockMode.autofill), ) @Test diff --git a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt index fba8e1a7fe..59058b31dd 100644 --- a/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt +++ b/feature/firmware/src/jvmTest/kotlin/org/meshtastic/feature/firmware/FirmwareUpdateViewModelFileTest.kt @@ -137,6 +137,7 @@ class FirmwareUpdateViewModelFileTest { fileHandler, TestApplicationCoroutineScope(testDispatcher), HiddenFeaturesUnlock(), + mock(MockMode.autofill), ) private fun firmwareUri(fileName: String): CommonUri = CommonUri.parse("file:///downloads/$fileName") diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt index aff51ae622..f2420e8858 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/CommonNodeRequestActions.kt @@ -25,6 +25,7 @@ import org.koin.core.annotation.Single import org.meshtastic.core.common.util.nowMillis import org.meshtastic.core.model.Position import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioController import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.UiText @@ -47,6 +48,7 @@ class CommonNodeRequestActions constructor( private val radioController: RadioController, private val snackbarManager: SnackbarManager, + private val analytics: PlatformAnalytics, ) : NodeRequestActions { private val _lastTracerouteTime = MutableStateFlow(null) @@ -62,6 +64,7 @@ constructor( override suspend fun requestUserInfo(destNum: Int, longName: String) { Logger.i { "Requesting UserInfo for '$destNum'" } radioController.requestUserInfo(destNum) + analytics.trackAction("user_info_request") showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.user_info, longName)) } @@ -76,6 +79,7 @@ constructor( override suspend fun requestPosition(destNum: Int, longName: String, position: Position) { Logger.i { "Requesting position for '$destNum'" } radioController.requestPosition(destNum, position) + analytics.trackAction("position_request") showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.position, longName)) } @@ -83,6 +87,7 @@ constructor( Logger.i { "Requesting telemetry for '$destNum'" } val packetId = radioController.generatePacketId() radioController.requestTelemetry(packetId, destNum, type.ordinal) + analytics.trackAction("telemetry_request", mapOf("telemetry_type" to type.name)) val typeRes = when (type) { @@ -102,6 +107,7 @@ constructor( Logger.i { "Requesting traceroute for '$destNum'" } val packetId = radioController.generatePacketId() radioController.requestTraceroute(packetId, destNum) + analytics.trackAction("traceroute_request") _lastTracerouteTime.value = nowMillis showFeedback(UiText.Resource(Res.string.requesting_from, Res.string.traceroute, longName)) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt index dc6e577500..7736de583a 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeManagementActions.kt @@ -23,6 +23,7 @@ import org.jetbrains.compose.resources.getString import org.koin.core.annotation.Single import org.meshtastic.core.model.Node import org.meshtastic.core.repository.NodeRepository +import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioController import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.favorite @@ -46,6 +47,7 @@ constructor( private val nodeRepository: NodeRepository, private val radioController: RadioController, private val alertManager: AlertManager, + private val analytics: PlatformAnalytics, ) { open fun requestRemoveNode(scope: CoroutineScope, node: Node, onAfterRemove: () -> Unit = {}) { alertManager.showAlert( @@ -114,6 +116,7 @@ constructor( open suspend fun setFavorite(nodeNum: Int, favorite: Boolean) { radioController.setFavorite(nodeNum, favorite) + analytics.trackAction("node_favorite", mapOf("favorite" to favorite)) } open suspend fun setNodeNotes(nodeNum: Int, notes: String) { diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt index fcfb7ce58b..7172e65e52 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/detail/NodeManagementActionsTest.kt @@ -45,6 +45,7 @@ class NodeManagementActionsTest { nodeRepository = nodeRepository, radioController = radioController, alertManager = alertManager, + analytics = mock(MockMode.autofill), ) @Test @@ -76,6 +77,7 @@ class NodeManagementActionsTest { nodeRepository = nodeRepository, radioController = radioController, alertManager = realAlertManager, + analytics = mock(MockMode.autofill), ) val node = Node(num = 123, user = User(long_name = "Test Node")) var afterRemoveCalled = false diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt index 093a670502..78ade787a7 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/channel/ChannelViewModel.kt @@ -84,8 +84,10 @@ class ChannelViewModel( } /** Set the radio config (also updates our saved copy in preferences). */ - fun setChannels(channelSet: ChannelSet) = - safeLaunch(tag = "setChannels") { importChannelSet(channelSet, radioController, radioConfigRepository) } + fun setChannels(channelSet: ChannelSet) = safeLaunch(tag = "setChannels") { + importChannelSet(channelSet, radioController, radioConfigRepository) + analytics.trackAction("channel_update", mapOf("num_channels" to channelSet.settings.size)) + } // Set the radio config (also updates our saved copy in preferences) fun setConfig(config: Config) { @@ -94,6 +96,7 @@ class ChannelViewModel( fun trackShare() { analytics.track("share", DataPair("content_type", "channel")) + analytics.trackAction("channel_share") } private inline fun updateLoraConfig(crossinline body: (Config.LoRaConfig) -> Config.LoRaConfig) { 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 29bb17745f..ea2696719b 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 @@ -70,6 +70,7 @@ import org.meshtastic.core.repository.MqttManager import org.meshtastic.core.repository.NodeRepository import org.meshtastic.core.repository.NodeRestartTracker import org.meshtastic.core.repository.PacketRepository +import org.meshtastic.core.repository.PlatformAnalytics import org.meshtastic.core.repository.RadioConfigRepository import org.meshtastic.core.repository.SecurityKeyBackupStore import org.meshtastic.core.repository.ServiceRepository @@ -161,6 +162,7 @@ open class RadioConfigViewModel( private val securityKeyBackupStore: SecurityKeyBackupStore, private val snackbarManager: SnackbarManager, private val nodeRestartTracker: NodeRestartTracker, + private val analytics: PlatformAnalytics, ) : ViewModel() { val lockdownTokenInfo = serviceRepository.lockdownTokenInfo @@ -590,8 +592,11 @@ open class RadioConfigViewModel( private fun sendAdminRequest(destNum: Int) { val route = radioConfigState.value.route + val isLocal = radioConfigState.value.isLocal _radioConfigState.update { it.copy(route = "") } // setter (response is PortNum.ROUTING_APP) + analytics.trackAction("admin_action", mapOf("route" to route.lowercase(), "is_remote" to !isLocal)) + val preserveFavorites = radioConfigState.value.nodeDbResetPreserveFavorites when (route) { diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt index d22b83362c..620b7858ed 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/channel/ChannelScreen.kt @@ -257,7 +257,10 @@ fun ChannelScreen( isWaiting = true radioConfigViewModel.setResponseStateLoading(ConfigRoute.CHANNELS) }, - onClickShare = { showShareDialog = true }, + onClickShare = { + viewModel.trackShare() + showShareDialog = true + }, ) } item { diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/ProfileRoundTripTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/ProfileRoundTripTest.kt index 4de314fbea..cedc8f9455 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/ProfileRoundTripTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/ProfileRoundTripTest.kt @@ -142,6 +142,7 @@ class ProfileRoundTripTest { fileService = fileService, mqttManager = mqttManager, lockdownCoordinator = FakeLockdownCoordinator(), + analytics = mock(MockMode.autofill), securityKeyBackupStore = securityKeyBackupStore, snackbarManager = snackbarManager, nodeRestartTracker = nodeRestartTracker, diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt index 5415317d46..51289a01f6 100644 --- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt +++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt @@ -184,6 +184,7 @@ class RadioConfigViewModelTest { fileService = fileService, mqttManager = mqttManager, lockdownCoordinator = FakeLockdownCoordinator(), + analytics = mock(MockMode.autofill), ) @Test @@ -975,6 +976,7 @@ class RadioConfigViewModelTest { fileService = fileService, mqttManager = mqttManager, lockdownCoordinator = FakeLockdownCoordinator(), + analytics = mock(MockMode.autofill), ) assertEquals(456, viewModel.destNode.value?.num) }