mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-15 14:48:34 -04:00
feat(analytics): report key user interactions as named RUM actions (#6654)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b4cc36043d
commit
9fa9f1e341
16 files changed
+74
-3
No files matched your search
@@ -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<String, Any>) {
|
||||
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)
|
||||
|
||||
+11
@@ -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<String, Any> = 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.
|
||||
|
||||
+8
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
+18
@@ -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>(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 {
|
||||
|
||||
+1
@@ -115,6 +115,7 @@ class FirmwareUpdateIntegrationTest {
|
||||
fileHandler,
|
||||
TestApplicationCoroutineScope(testDispatcher),
|
||||
HiddenFeaturesUnlock(),
|
||||
mock(MockMode.autofill),
|
||||
)
|
||||
|
||||
@Test
|
||||
|
||||
+1
@@ -138,6 +138,7 @@ class FirmwareUpdateViewModelTest {
|
||||
fileHandler,
|
||||
TestApplicationCoroutineScope(testDispatcher),
|
||||
hiddenFeaturesUnlock,
|
||||
mock(MockMode.autofill),
|
||||
)
|
||||
|
||||
@Test
|
||||
|
||||
+1
@@ -137,6 +137,7 @@ class FirmwareUpdateViewModelFileTest {
|
||||
fileHandler,
|
||||
TestApplicationCoroutineScope(testDispatcher),
|
||||
HiddenFeaturesUnlock(),
|
||||
mock(MockMode.autofill),
|
||||
)
|
||||
|
||||
private fun firmwareUri(fileName: String): CommonUri = CommonUri.parse("file:///downloads/$fileName")
|
||||
|
||||
+6
@@ -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<Long?>(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))
|
||||
}
|
||||
|
||||
+3
@@ -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) {
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+5
-2
@@ -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) {
|
||||
|
||||
+5
@@ -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) {
|
||||
|
||||
+4
-1
@@ -257,7 +257,10 @@ fun ChannelScreen(
|
||||
isWaiting = true
|
||||
radioConfigViewModel.setResponseStateLoading(ConfigRoute.CHANNELS)
|
||||
},
|
||||
onClickShare = { showShareDialog = true },
|
||||
onClickShare = {
|
||||
viewModel.trackShare()
|
||||
showShareDialog = true
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
|
||||
+1
@@ -142,6 +142,7 @@ class ProfileRoundTripTest {
|
||||
fileService = fileService,
|
||||
mqttManager = mqttManager,
|
||||
lockdownCoordinator = FakeLockdownCoordinator(),
|
||||
analytics = mock(MockMode.autofill),
|
||||
securityKeyBackupStore = securityKeyBackupStore,
|
||||
snackbarManager = snackbarManager,
|
||||
nodeRestartTracker = nodeRestartTracker,
|
||||
|
||||
+2
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user