diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index d8e859a77..3a96b0908 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -241,7 +241,7 @@ class MainActivity : errormsg("Failed to start service from activity - but ignoring because bind will work ${ex.message}") } - mesh.connect(this, MeshService.createIntent(this), BIND_AUTO_CREATE + BIND_ABOVE_CLIENT) + mesh.connect(this, MeshService.createIntent(), BIND_AUTO_CREATE + BIND_ABOVE_CLIENT) } override fun onStart() { diff --git a/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt b/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt index 44dd0c766..aa1e04ea0 100644 --- a/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/database/NodeRepository.kt @@ -124,6 +124,11 @@ constructor( nodeInfoDao.clearNodeInfo() } + suspend fun installNodeDb(nodes: List) = withContext(dispatchers.io) { + nodeInfoDao.clearNodeInfo() + nodeInfoDao.putAll(nodes) + } + suspend fun clearNodeDB() = withContext(dispatchers.io) { nodeInfoDao.clearNodeInfo() } suspend fun deleteNode(num: Int) = withContext(dispatchers.io) { diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index 283e0f704..60ca0f596 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -749,10 +749,12 @@ constructor( val connectionState get() = radioConfigRepository.connectionState - fun isConnected() = connectionState.value != com.geeksville.mesh.service.ConnectionState.DISCONNECTED + fun isConnected() = isConnectedStateFlow.value - val isConnected = - radioConfigRepository.connectionState.map { it != com.geeksville.mesh.service.ConnectionState.DISCONNECTED } + val isConnectedStateFlow = + radioConfigRepository.connectionState + .map { it.isConnected() } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) private val _requestChannelSet = MutableStateFlow(null) val requestChannelSet: StateFlow diff --git a/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt b/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt index 0eb4f0980..c7a1e7d92 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/NavGraph.kt @@ -19,8 +19,10 @@ package com.geeksville.mesh.navigation import androidx.annotation.StringRes import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hasRoute import androidx.navigation.NavHostController @@ -43,32 +45,25 @@ enum class AdminRoute(@StringRes val title: Int) { const val DEEP_LINK_BASE_URI = "meshtastic://meshtastic" -@Serializable -sealed interface Graph : Route +@Serializable sealed interface Graph : Route + @Serializable sealed interface Route { - @Serializable - data object DebugPanel : Route + @Serializable data object DebugPanel : Route } -fun NavDestination.isConfigRoute(): Boolean { - return ConfigRoute.entries.any { hasRoute(it.route::class) } || - ModuleRoute.entries.any { hasRoute(it.route::class) } -} +fun NavDestination.isConfigRoute(): Boolean = + ConfigRoute.entries.any { hasRoute(it.route::class) } || ModuleRoute.entries.any { hasRoute(it.route::class) } -fun NavDestination.isNodeDetailRoute(): Boolean { - return NodeDetailRoute.entries.any { hasRoute(it.route::class) } -} +fun NavDestination.isNodeDetailRoute(): Boolean = NodeDetailRoute.entries.any { hasRoute(it.route::class) } -fun NavDestination.showLongNameTitle(): Boolean { - - return !this.isTopLevel() && ( - this.hasRoute() || - this.hasRoute() || - this.isConfigRoute() || - this.isNodeDetailRoute() - ) -} +fun NavDestination.showLongNameTitle(): Boolean = !this.isTopLevel() && + ( + this.hasRoute() || + this.hasRoute() || + this.isConfigRoute() || + this.isNodeDetailRoute() + ) @Suppress("LongMethod") @Composable @@ -78,9 +73,11 @@ fun NavGraph( bluetoothViewModel: BluetoothViewModel = hiltViewModel(), navController: NavHostController = rememberNavController(), ) { + val isConnected by uIViewModel.isConnectedStateFlow.collectAsStateWithLifecycle(false) NavHost( navController = navController, - startDestination = if (uIViewModel.isConnected()) { + startDestination = + if (isConnected) { NodesRoutes.NodesGraph } else { ConnectionsRoutes.ConnectionsGraph @@ -88,7 +85,7 @@ fun NavGraph( modifier = modifier, ) { contactsGraph(navController, uIViewModel) - nodesGraph(navController, uIViewModel,) + nodesGraph(navController, uIViewModel) mapGraph(navController, uIViewModel) channelsGraph(navController, uIViewModel) connectionsGraph(navController, uIViewModel, bluetoothViewModel) diff --git a/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt index 7d9ca1ece..06e27095f 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/datastore/RadioConfigRepository.kt @@ -91,6 +91,10 @@ constructor( nodeDB.installMyNodeInfo(mi) } + suspend fun installNodeDb(nodes: List) { + nodeDB.installNodeDb(nodes) + } + suspend fun insertMetadata(fromNum: Int, metadata: DeviceMetadata) { nodeDB.insertMetadata(MetadataEntity(fromNum, metadata)) } diff --git a/app/src/main/java/com/geeksville/mesh/service/ConnectionRouter.kt b/app/src/main/java/com/geeksville/mesh/service/ConnectionRouter.kt deleted file mode 100644 index b7d9b7d92..000000000 --- a/app/src/main/java/com/geeksville/mesh/service/ConnectionRouter.kt +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright (c) 2025 Meshtastic LLC - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.geeksville.mesh.service - -import com.geeksville.mesh.CoroutineDispatchers -import com.geeksville.mesh.LocalOnlyProtos -import com.geeksville.mesh.android.BuildUtils.warn -import com.geeksville.mesh.repository.datastore.RadioConfigRepository -import com.geeksville.mesh.repository.radio.RadioInterfaceService -import com.geeksville.mesh.repository.radio.RadioServiceConnectionState -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.coroutines.cancellation.CancellationException -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.Duration.Companion.seconds - -@Singleton -class ConnectionRouter -@Inject -constructor( - private val radioInterface: RadioInterfaceService, - private val radioConfigRepository: RadioConfigRepository, - private val dispatchers: CoroutineDispatchers, -) { - - private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED) - val connectionState: StateFlow = _connectionState.asStateFlow() - - private val routerJob = Job() - private val routerScope = CoroutineScope(dispatchers.io + routerJob) - private var sleepTimeout: Job? = null - - private var localConfig: LocalOnlyProtos.LocalConfig = LocalOnlyProtos.LocalConfig.getDefaultInstance() - - init { - // We need to keep our local radio config up to date - radioConfigRepository.localConfigFlow.onEach { localConfig = it }.launchIn(routerScope) - } - - fun start() { - // This is where we will start listening to the radio interface - radioInterface.connectionState.onEach(::onRadioConnectionState).launchIn(routerScope) - } - - fun stop() { - routerJob.cancel() - } - - fun setDeviceAddress(address: String?): Boolean { - _connectionState.value = ConnectionState.CONNECTING - return radioInterface.setDeviceAddress(address) - } - - private fun onRadioConnectionState(state: RadioServiceConnectionState) { - // sleep now disabled by default on ESP32, permanent is true unless light sleep enabled - val isRouter = localConfig.device.role == com.geeksville.mesh.ConfigProtos.Config.DeviceConfig.Role.ROUTER - val lsEnabled = localConfig.power.isPowerSaving || isRouter - val connected = state.isConnected - val permanent = state.isPermanent || !lsEnabled - onConnectionChanged( - when { - connected -> ConnectionState.CONNECTED - permanent -> ConnectionState.DISCONNECTED - else -> ConnectionState.DEVICE_SLEEP - }, - ) - } - - private fun onConnectionChanged(c: ConnectionState) { - // Cancel any existing timeouts - sleepTimeout?.cancel() - sleepTimeout = null - - _connectionState.value = c - - if (c == ConnectionState.DEVICE_SLEEP) { - // Have our timeout fire in the appropriate number of seconds - sleepTimeout = - routerScope.launch { - try { - // If we have a valid timeout, wait that long (+30 seconds) otherwise, just wait 30 seconds - val timeout = (localConfig.power?.lsSecs ?: 0).milliseconds + 30.seconds - // Log.d(TAG, "Waiting for sleeping device, timeout=$timeout secs") - delay(timeout) - // Log.w(TAG, "Device timeout out, setting disconnected") - onConnectionChanged(ConnectionState.DISCONNECTED) - } catch (ex: CancellationException) { - warn("Sleep timeout cancelled: ${ex.message}") - } - } - } - } -} diff --git a/app/src/main/java/com/geeksville/mesh/service/ConnectionState.kt b/app/src/main/java/com/geeksville/mesh/service/ConnectionState.kt index 2643e898b..003f83806 100644 --- a/app/src/main/java/com/geeksville/mesh/service/ConnectionState.kt +++ b/app/src/main/java/com/geeksville/mesh/service/ConnectionState.kt @@ -21,9 +21,6 @@ enum class ConnectionState { /** We are disconnected from the device, and we should be trying to reconnect. */ DISCONNECTED, - /** We are currently attempting to connect to the device. */ - CONNECTING, - /** We are connected to the device and communicating normally. */ CONNECTED, @@ -32,7 +29,5 @@ enum class ConnectionState { ; - fun isConnected() = this == CONNECTED - - fun isDisconnected() = this == DISCONNECTED + fun isConnected() = this != DISCONNECTED } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index 3e43652e9..f1007bff3 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -17,7 +17,6 @@ package com.geeksville.mesh.service -import android.Manifest import android.annotation.SuppressLint import android.app.Service import android.content.Context @@ -27,7 +26,6 @@ import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder import android.os.RemoteException -import androidx.annotation.RequiresPermission import androidx.core.app.ServiceCompat import androidx.core.content.edit import androidx.core.location.LocationCompat @@ -43,6 +41,7 @@ import com.geeksville.mesh.IMeshService import com.geeksville.mesh.LocalOnlyProtos.LocalConfig import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig import com.geeksville.mesh.MeshProtos +import com.geeksville.mesh.MeshProtos.FromRadio.PayloadVariantCase import com.geeksville.mesh.MeshProtos.MeshPacket import com.geeksville.mesh.MeshProtos.ToRadio import com.geeksville.mesh.MeshUser @@ -57,6 +56,7 @@ import com.geeksville.mesh.R import com.geeksville.mesh.StoreAndForwardProtos import com.geeksville.mesh.TelemetryProtos import com.geeksville.mesh.TelemetryProtos.LocalStats +import com.geeksville.mesh.XmodemProtos import com.geeksville.mesh.analytics.DataPair import com.geeksville.mesh.android.GeeksvilleApplication import com.geeksville.mesh.android.Logging @@ -80,6 +80,7 @@ import com.geeksville.mesh.repository.datastore.RadioConfigRepository import com.geeksville.mesh.repository.location.LocationRepository import com.geeksville.mesh.repository.network.MQTTRepository import com.geeksville.mesh.repository.radio.RadioInterfaceService +import com.geeksville.mesh.repository.radio.RadioServiceConnectionState import com.geeksville.mesh.telemetry import com.geeksville.mesh.user import com.geeksville.mesh.util.anonymize @@ -92,6 +93,7 @@ import com.google.protobuf.InvalidProtocolBufferException import dagger.Lazy import dagger.hilt.android.AndroidEntryPoint import java8.util.concurrent.CompletableFuture +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -102,7 +104,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.isActive import kotlinx.coroutines.withTimeoutOrNull import java.util.Random import java.util.UUID @@ -110,7 +111,6 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException -import java.util.concurrent.atomic.AtomicLong import javax.inject.Inject import kotlin.math.absoluteValue @@ -127,13 +127,11 @@ sealed class ServiceAction { } /** - * Handles all communication with android apps and the Meshtastic device. It maintains an internal model of the network - * state, manages device configurations, and processes incoming/outgoing packets. + * Handles all the communication with android apps. Also keeps an internal model of the network state. * - * Note: This service will go away once all clients are unbound from it. Warning: Do not override toString, it causes - * infinite recursion on some Android versions (because contextWrapper.getResources calls toString). + * Note: this service will go away once all clients are unbound from it. Warning: do not override toString, it causes + * infinite recursion on some androids (because contextWrapper.getResources calls to string */ -@Suppress("MagicNumber") @AndroidEntryPoint class MeshService : Service(), @@ -154,20 +152,19 @@ class MeshService : @Inject lateinit var serviceNotifications: MeshServiceNotifications - @Inject lateinit var connectionRouter: ConnectionRouter - companion object : Logging { - private const val MESH_PREFS_NAME = "mesh-prefs" - private const val DEVICE_ADDRESS_KEY = "device_address" - private const val ADMIN_CHANNEL_NAME = "admin" // Intents broadcast by MeshService + private fun actionReceived(portNum: String) = "$prefix.RECEIVED.$portNum" - /** Generates a RECEIVED action filter string for a given port number. */ + // generate a RECEIVED action filter string that includes either the portnumber as an int, + // or preferably a + // symbolic name from portnums.proto fun actionReceived(portNum: Int): String { val portType = Portnums.PortNum.forNumber(portNum) val portStr = portType?.toString() ?: portNum.toString() + return actionReceived(portStr) } @@ -186,14 +183,21 @@ class MeshService : class NoDeviceConfigException(message: String = "No radio settings received (is our app too old?)") : RadioNotConnectedException(message) - /** Initiates a device address change and starts the service. */ + /** + * Talk to our running service and try to set a new device address. And then immediately call start on the + * service to possibly promote our service to be a foreground service. + */ fun changeDeviceAddress(context: Context, service: IMeshService, address: String?) { service.setDeviceAddress(address) - startService(context) // Ensure service is started/foregrounded if needed + startService(context) } - fun createIntent(context: Context): Intent = Intent(context, MeshService::class.java) + fun createIntent() = Intent().setClassName("com.geeksville.mesh", "com.geeksville.mesh.service.MeshService") + /** + * The minimum firmware version we know how to talk to. We'll still be able to talk to 2.0 firmwares but only + * well enough to ask them to firmware update. + */ val minDeviceVersion = DeviceVersion(BuildConfig.MIN_FW_VERSION) val absoluteMinDeviceVersion = DeviceVersion(BuildConfig.ABS_MIN_FW_VERSION) @@ -205,39 +209,38 @@ class MeshService : private var previousSummary: String? = null private var previousStats: LocalStats? = null - private val clientPackages = ConcurrentHashMap() - private val serviceBroadcasts by lazy { + // A mapping of receiver class name to package name - used for explicit broadcasts + private val clientPackages = mutableMapOf() + private val serviceBroadcasts = MeshServiceBroadcasts(this, clientPackages) { - connectionRouter.connectionState.value.also { radioConfigRepository.setConnectionState(it) } + connectionState.also { radioConfigRepository.setConnectionState(it) } } - } private val serviceJob = Job() private val serviceScope = CoroutineScope(Dispatchers.IO + serviceJob) + private var connectionState = ConnectionState.DISCONNECTED - private val locationJobLock = Any() private var locationFlow: Job? = null private var mqttMessageFlow: Job? = null - // Battery thresholds and cooldowns private val batteryPercentUnsupported = 0.0 private val batteryPercentLowThreshold = 20 private val batteryPercentLowDivisor = 5 private val batteryPercentCriticalThreshold = 5 - private val batteryPercentCooldownSeconds = 1500L - private val batteryPercentCooldowns = ConcurrentHashMap() + private val batteryPercentCooldownSeconds = 1500 + private val batteryPercentCooldowns: HashMap = HashMap() private fun getSenderName(packet: DataPacket?): String { - val nodeId = packet?.from ?: return getString(R.string.unknown_username) - return nodeDBbyID[nodeId]?.user?.longName ?: getString(R.string.unknown_username) + val name = nodeDBbyID[packet?.from]?.user?.longName + return name ?: getString(R.string.unknown_username) } - private val notificationSummary: String + private val notificationSummary get() = - when (connectionRouter.connectionState.value) { - ConnectionState.CONNECTED -> getString(R.string.connected_count, numOnlineNodes.toString()) + when (connectionState) { + ConnectionState.CONNECTED -> getString(R.string.connected_count).format(numOnlineNodes) + ConnectionState.DISCONNECTED -> getString(R.string.disconnected) ConnectionState.DEVICE_SLEEP -> getString(R.string.device_sleeping) - ConnectionState.CONNECTING -> getString(R.string.connecting_to_device) } private var localStatsTelemetry: TelemetryProtos.Telemetry? = null @@ -247,18 +250,19 @@ class MeshService : private val localStatsUpdatedAtMillis: Long? get() = localStatsTelemetry?.time?.let { it * 1000L } - /** Starts location requests if permissions are granted and not already active. */ - @RequiresPermission(allOf = [Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION]) + /** start our location requests (if they weren't already running) */ private fun startLocationRequests() { - synchronized(locationJobLock) { - if (locationFlow?.isActive == true) return + // If we're already observing updates, don't register again + if (locationFlow?.isActive == true) return - if (hasLocationPermission()) { - locationFlow = - locationRepository - .getLocations() - .onEach { location -> - val positionBuilder = position { + @SuppressLint("MissingPermission") + if (hasLocationPermission()) { + locationFlow = + locationRepository + .getLocations() + .onEach { location -> + sendPosition( + position { latitudeI = Position.degI(location.latitude) longitudeI = Position.degI(location.longitude) if (LocationCompat.hasMslAltitude(location)) { @@ -269,50 +273,52 @@ class MeshService : groundSpeed = location.speed.toInt() groundTrack = location.bearing.toInt() locationSource = MeshProtos.Position.LocSource.LOC_EXTERNAL - } - sendPosition(positionBuilder) - } - .launchIn(serviceScope) - } + }, + ) + } + .launchIn(serviceScope) } } private fun stopLocationRequests() { - synchronized(locationJobLock) { - locationFlow - ?.takeIf { it.isActive } - ?.let { - info("Stopping location requests") - it.cancel() - locationFlow = null - } + if (locationFlow?.isActive == true) { + info("Stopping location requests") + locationFlow?.cancel() + locationFlow = null } } - private fun sendToRadio(toRadioBuilder: ToRadio.Builder) { - val builtProto = toRadioBuilder.build() - debug("Sending to radio: ${builtProto.toPIIString()}") - radioInterfaceService.sendToRadio(builtProto.toByteArray()) + /** + * Send a command/packet to our radio. But cope with the possibility that we might start up before we are fully + * bound to the RadioInterfaceService + */ + private fun sendToRadio(p: ToRadio.Builder) { + val built = p.build() + debug("Sending to radio ${built.toPIIString()}") + val b = built.toByteArray() - if (toRadioBuilder.hasPacket()) { - val packet = toRadioBuilder.packet - changeStatus(packet.id, MessageStatus.ENROUTE) - if (packet.hasDecoded()) { - insertMeshLog( - MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "PacketSent", // Clarified type - received_date = System.currentTimeMillis(), - raw_message = packet.toString(), - fromNum = myNodeNum, // Correctly use myNodeNum for sent packets - portNum = packet.decoded.portnumValue, - fromRadio = fromRadio { this.packet = packet }, - ), + radioInterfaceService.sendToRadio(b) + changeStatus(p.packet.id, MessageStatus.ENROUTE) + + if (p.packet.hasDecoded()) { + val packetToSave = + MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "Packet", + received_date = System.currentTimeMillis(), + raw_message = p.packet.toString(), + fromNum = p.packet.from, + portNum = p.packet.decoded.portnumValue, + fromRadio = fromRadio { packet = p.packet }, ) - } + insertMeshLog(packetToSave) } } + /** + * Send a mesh packet to the radio, if the radio is not currently connected this function will throw + * NotConnectedException + */ private fun sendToRadio(packet: MeshPacket) { queuedPackets.add(packet) startPacketQueue() @@ -329,9 +335,10 @@ class MeshService : private fun updateMessageNotification(contactKey: String, dataPacket: DataPacket) { val message: String = when (dataPacket.dataType) { - Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> dataPacket.text ?: return - Portnums.PortNum.WAYPOINT_APP_VALUE -> - getString(R.string.waypoint_received, dataPacket.waypoint?.name ?: "") + Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> dataPacket.text!! + Portnums.PortNum.WAYPOINT_APP_VALUE -> { + getString(R.string.waypoint_received, dataPacket.waypoint!!.name) + } else -> return } @@ -345,66 +352,82 @@ class MeshService : override fun onCreate() { super.onCreate() - sharedPreferences = getSharedPreferences(MESH_PREFS_NAME, Context.MODE_PRIVATE) - _lastAddress.value = sharedPreferences.getString(DEVICE_ADDRESS_KEY, null) ?: NO_DEVICE_SELECTED + sharedPreferences = getSharedPreferences("mesh-prefs", Context.MODE_PRIVATE) + _lastAddress.value = sharedPreferences.getString("device_address", null) ?: NO_DEVICE_SELECTED info("Creating mesh service") serviceNotifications.initChannels() - connectionRouter.start() - + // Switch to the IO thread serviceScope.handledLaunch { radioInterfaceService.connect() } - - connectionRouter.connectionState - .onEach { state -> - when (state) { - ConnectionState.CONNECTED -> startConnect() - ConnectionState.DEVICE_SLEEP -> startDeviceSleep() - ConnectionState.DISCONNECTED -> startDisconnect() - else -> Unit - } - } - .launchIn(serviceScope) - + radioInterfaceService.connectionState.onEach(::onRadioConnectionState).launchIn(serviceScope) radioInterfaceService.receivedData.onEach(::onReceiveFromRadio).launchIn(serviceScope) radioConfigRepository.localConfigFlow.onEach { localConfig = it }.launchIn(serviceScope) radioConfigRepository.moduleConfigFlow.onEach { moduleConfig = it }.launchIn(serviceScope) radioConfigRepository.channelSetFlow.onEach { channelSet = it }.launchIn(serviceScope) radioConfigRepository.serviceAction.onEach(::onServiceAction).launchIn(serviceScope) + + loadSettings() // Load our last known node DB + + // the rest of our init will happen once we are in radioConnection.onServiceConnected } + /** If someone binds to us, this will be called after on create */ override fun onBind(intent: Intent?): IBinder = binder + /** + * Called when the service is started or restarted. This method manages the foreground state of the service. + * + * It attempts to start the service in the foreground with a notification. If `startForeground` fails, for example, + * due to a `SecurityException` on Android 13+ because the `POST_NOTIFICATIONS` permission is missing, it logs an + * error* and returns `START_NOT_STICKY` to prevent the service from becoming sticky in a broken state. + * + * If the service is not intended to be in the foreground (e.g., no device is connected), it stops the foreground + * state and returns `START_NOT_STICKY`. Otherwise, it returns `START_STICKY`. + * + * @param intent The Intent supplied to `startService(Intent)`, as modified by the system. + * @param flags Additional data about this start request. + * @param startId A unique integer representing this specific request to start. + * @return The return value indicates what semantics the system should use for the service's current started state. + * See [Service.onStartCommand] for details. + */ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - val deviceAddress = radioInterfaceService.getBondedDeviceAddress() - val wantForeground = deviceAddress != null && deviceAddress != NO_DEVICE_SELECTED + val a = radioInterfaceService.getBondedDeviceAddress() + val wantForeground = a != null && a != NO_DEVICE_SELECTED - info("Requesting foreground service: $wantForeground") + info("Requesting foreground service=$wantForeground") - val notification = serviceNotifications.createServiceStateNotification(notificationSummary) - val foregroundServiceType = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - if (hasLocationPermission()) { - ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST - } else { - ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE - } - } else { - 0 - } + // We always start foreground because that's how our service is always started (if we didn't + // then android would + // kill us) + // but if we don't really need foreground we immediately stop it. + val notification = serviceNotifications.updateServiceStateNotification(notificationSummary) try { - ServiceCompat.startForeground(this, serviceNotifications.notifyId, notification, foregroundServiceType) - } catch (ex: SecurityException) { - val errorMessage = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - "startForeground failed, likely due to missing POST_NOTIFICATIONS permission on Android 13+" + ServiceCompat.startForeground( + this, + MeshServiceNotifications.SERVICE_NOTIFY_ID, + notification, + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + if (hasLocationPermission()) { + ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST + } else { + ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE + } } else { - "startForeground failed" - } - errormsg(errorMessage, ex) - return START_NOT_STICKY // Prevent service becoming sticky in a broken state + 0 + }, + ) + } catch (ex: SecurityException) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + errormsg( + "startForeground failed, likely due to missing POST_NOTIFICATIONS permission on Android 13+", + ex, + ) + } else { + errormsg("startForeground failed", ex) + } + return START_NOT_STICKY } - return if (!wantForeground) { ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) START_NOT_STICKY @@ -415,122 +438,127 @@ class MeshService : override fun onDestroy() { info("Destroying mesh service") + + // Make sure we aren't using the notification first ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + super.onDestroy() serviceJob.cancel() - connectionRouter.stop() } - /** - * Resets in-memory app state variables. This is crucial when switching to a new device connection to prevent state - * from a previous session from affecting the new one. It ensures a clean slate for node information, - * configurations, pending operations, and cached data. - * - * It does *not* clear persisted DataStore settings, only the Nodes DB from Room. - */ - private suspend fun resetState() { - debug("Discarding NodeDB and resetting all service state for new device connection") + // + // BEGINNING OF MODEL - FIXME, move elsewhere + // - // Clear only the node database, not persisted configs - radioConfigRepository.clearNodeDB() + private fun loadSettings() = serviceScope.handledLaunch { + discardNodeDB() // Get rid of any old state + myNodeInfo = radioConfigRepository.myNodeInfo.value + nodeDBbyNodeNum.putAll(radioConfigRepository.getNodeDBbyNum()) + // Note: we do not haveNodeDB = true because that means we've got a valid db from a real + // device (rather than + // this possibly stale hint) + } - // Core Node and Config data (in-memory only) + /** discard entire node db & message state - used when downloading a new db from the device */ + private fun discardNodeDB() { + debug("Discarding NodeDB") myNodeInfo = null - rawMyNodeInfo = null nodeDBbyNodeNum.clear() - _nodeDBbyID.clear() - localConfig = LocalConfig.getDefaultInstance() - moduleConfig = LocalModuleConfig.getDefaultInstance() - channelSet = AppOnlyProtos.ChannelSet.getDefaultInstance() - - localStatsTelemetry = null - sessionPasskey = ByteString.EMPTY - - // Pending operations and cached data - currentPacketId = Random(System.currentTimeMillis()).nextLong().absoluteValue - packetIdGenerator.set(Random(System.currentTimeMillis()).nextLong().absoluteValue) - offlineSentPackets.clear() - stopPacketQueue() - - connectTimeMsec = 0L - - stopLocationRequests() - stopMqttClientProxy() - - previousSummary = null - previousStats = null - - batteryPercentCooldowns.clear() - - info("MeshService app state has been reset for a new device session.") + haveNodeDB = false } private var myNodeInfo: MyNodeEntity? = null - private var rawMyNodeInfo: MeshProtos.MyNodeInfo? = null - private var currentPacketId = Random(System.currentTimeMillis()).nextLong().absoluteValue + private val configTotal by lazy { ConfigProtos.Config.getDescriptor().fields.size } private val moduleTotal by lazy { ModuleConfigProtos.ModuleConfig.getDescriptor().fields.size } private var sessionPasskey: ByteString = ByteString.EMPTY + private var localConfig: LocalConfig = LocalConfig.getDefaultInstance() private var moduleConfig: LocalModuleConfig = LocalModuleConfig.getDefaultInstance() private var channelSet: AppOnlyProtos.ChannelSet = AppOnlyProtos.ChannelSet.getDefaultInstance() + // True after we've done our initial node db init + @Volatile private var haveNodeDB = false + + // The database of active nodes, index is the node number private val nodeDBbyNodeNum = ConcurrentHashMap() - private val _nodeDBbyID = ConcurrentHashMap() // Cached map for ID lookups - val nodeDBbyID: Map - get() = _nodeDBbyID // Expose immutable view if needed externally - private fun toNodeInfo(nodeNum: Int): NodeEntity = - nodeDBbyNodeNum[nodeNum] ?: throw NodeNumNotFoundException(nodeNum) + // The database of active nodes, index is the node user ID string + // NOTE: some NodeInfos might be in only nodeDBbyNodeNum (because we don't yet know an ID). + private val nodeDBbyID + get() = nodeDBbyNodeNum.mapKeys { it.value.user.id } - private fun toNodeID(nodeNum: Int): String = when (nodeNum) { - DataPacket.NODENUM_BROADCAST -> DataPacket.ID_BROADCAST - else -> nodeDBbyNodeNum[nodeNum]?.user?.id ?: DataPacket.nodeNumToDefaultId(nodeNum) + // + // END OF MODEL + // + + @Suppress("UnusedPrivateMember") + private val deviceVersion + get() = DeviceVersion(myNodeInfo?.firmwareVersion ?: "") + + @Suppress("UnusedPrivateMember") + private val appVersion + get() = BuildConfig.VERSION_CODE + + private val minAppVersion + get() = myNodeInfo?.minAppVersion ?: 0 + + // Map a nodenum to a node, or throw an exception if not found + private fun toNodeInfo(n: Int) = nodeDBbyNodeNum[n] ?: throw NodeNumNotFoundException(n) + + /** + * Map a nodeNum to the nodeId string If we have a NodeInfo for this ID we prefer to return the string ID inside the + * user record. but some nodes might not have a user record at all (because not yet received), in that case, we + * return a hex version of the ID just based on the number + */ + private fun toNodeID(n: Int): String = if (n == DataPacket.NODENUM_BROADCAST) { + DataPacket.ID_BROADCAST + } else { + nodeDBbyNodeNum[n]?.user?.id ?: DataPacket.nodeNumToDefaultId(n) } - private fun getOrCreateNodeInfo(nodeNum: Int, channel: Int = 0): NodeEntity = nodeDBbyNodeNum.getOrPut(nodeNum) { - val userId = DataPacket.nodeNumToDefaultId(nodeNum) + // given a nodeNum, return a db entry - creating if necessary + private fun getOrCreateNodeInfo(n: Int, channel: Int = 0) = nodeDBbyNodeNum.getOrPut(n) { + val userId = DataPacket.nodeNumToDefaultId(n) val defaultUser = user { id = userId - longName = "Meshtastic ${userId.takeLast(4)}" - shortName = userId.takeLast(4) + longName = "Meshtastic ${userId.takeLast(n = 4)}" + shortName = userId.takeLast(n = 4) hwModel = MeshProtos.HardwareModel.UNSET } - NodeEntity( - num = nodeNum, - user = defaultUser, - longName = defaultUser.longName, - channel = channel, - ).also { newEntity -> - if (newEntity.user.id.isNotEmpty()) { - _nodeDBbyID[newEntity.user.id] = newEntity - } - } + + NodeEntity(num = n, user = defaultUser, longName = defaultUser.longName, channel = channel) } private val hexIdRegex = """\!([0-9A-Fa-f]+)""".toRegex() - private fun toNodeInfo(id: String): NodeEntity = _nodeDBbyID[id] - ?: run { - val hexStr = hexIdRegex.matchEntire(id)?.groups?.get(1)?.value - when { + // Map a userid to a node/ node num, or throw an exception if not found + // We prefer to find nodes based on their assigned IDs, but if no ID has been assigned to a + // node, we can also find + // it based on node number + private fun toNodeInfo(id: String): NodeEntity { + // If this is a valid hexaddr will be !null + val hexStr = hexIdRegex.matchEntire(id)?.groups?.get(1)?.value + + return nodeDBbyID[id] + ?: when { id == DataPacket.ID_LOCAL -> toNodeInfo(myNodeNum) hexStr != null -> { - val nodeNum = hexStr.toLong(16).toInt() - nodeDBbyNodeNum[nodeNum] ?: throw IdNotFoundException(id) + val n = hexStr.toLong(16).toInt() + nodeDBbyNodeNum[n] ?: throw IdNotFoundException(id) } else -> throw InvalidNodeIdException(id) } - } + } - private fun getUserName(num: Int): String = - radioConfigRepository.getUser(num).let { "${it.longName} (${it.shortName})" } + private fun getUserName(num: Int): String = with(radioConfigRepository.getUser(num)) { "$longName ($shortName)" } - private val numNodes: Int + private val numNodes get() = nodeDBbyNodeNum.size - private val numOnlineNodes: Int + /** How many nodes are currently online (including our local node) */ + private val numOnlineNodes get() = nodeDBbyNodeNum.values.count { it.isOnline } private fun toNodeNum(id: String): Int = when (id) { @@ -539,6 +567,7 @@ class MeshService : else -> toNodeInfo(id).num } + // A helper function that makes it easy to update node info objects private inline fun updateNodeInfo( nodeNum: Int, withBroadcast: Boolean = true, @@ -546,19 +575,9 @@ class MeshService : crossinline updateFn: (NodeEntity) -> Unit, ) { val info = getOrCreateNodeInfo(nodeNum, channel) - val oldUserId = info.user.id - updateFn(info) - val newUserId = info.user.id - if (oldUserId.isNotEmpty() && oldUserId != newUserId) { - _nodeDBbyID.remove(oldUserId) - } - if (newUserId.isNotEmpty()) { - _nodeDBbyID[newUserId] = info - } - - if (info.user.id.isNotEmpty()) { + if (info.user.id.isNotEmpty() && haveNodeDB) { serviceScope.handledLaunch { radioConfigRepository.upsert(info) } } @@ -567,35 +586,48 @@ class MeshService : } } - private val myNodeNum: Int - get() = myNodeInfo?.myNodeNum ?: throw RadioNotConnectedException("Local node information not yet available") + // My node num + private val myNodeNum + get() = myNodeInfo?.myNodeNum ?: throw RadioNotConnectedException("We don't yet have our myNodeInfo") - private val myNodeID: String + // My node ID string + private val myNodeID get() = toNodeID(myNodeNum) + // Admin channel index private val MeshPacket.Builder.adminChannelIndex: Int get() = when { - myNodeNum == to -> 0 // Admin channel to self is 0 + myNodeNum == to -> 0 nodeDBbyNodeNum[myNodeNum]?.hasPKC == true && nodeDBbyNodeNum[to]?.hasPKC == true -> DataPacket.PKC_CHANNEL_INDEX else -> - channelSet.settingsList - .indexOfFirst { it.name.equals(ADMIN_CHANNEL_NAME, ignoreCase = true) } - .coerceAtLeast(0) + channelSet.settingsList.indexOfFirst { it.name.equals("admin", ignoreCase = true) }.coerceAtLeast(0) } - private fun newMeshPacketTo(nodeNum: Int): MeshPacket.Builder = MeshPacket.newBuilder().apply { - from = 0 // Device sets this to myNodeNum - to = nodeNum + // Generate a new mesh packet builder with our node as the sender, and the specified node num + private fun newMeshPacketTo(idNum: Int) = MeshPacket.newBuilder().apply { + if (myNodeInfo == null) { + throw RadioNotConnectedException() + } + + from = 0 // don't add myNodeNum + + to = idNum } - private fun newMeshPacketTo(id: String): MeshPacket.Builder = newMeshPacketTo(toNodeNum(id)) + /** + * Generate a new mesh packet builder with our node as the sender, and the specified recipient + * + * If id is null we assume a broadcast message + */ + private fun newMeshPacketTo(id: String) = newMeshPacketTo(toNodeNum(id)) + /** Helper to make it easy to build a subpacket in the proper protobufs */ private fun MeshPacket.Builder.buildMeshPacket( wantAck: Boolean = false, - id: Int = generatePacketId(), + id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one hopLimit: Int = localConfig.lora.hopLimit, channel: Int = 0, priority: MeshPacket.Priority = MeshPacket.Priority.UNSET, @@ -605,38 +637,44 @@ class MeshService : this.id = id this.hopLimit = hopLimit this.priority = priority - this.decoded = MeshProtos.Data.newBuilder().apply(initFn).build() + decoded = MeshProtos.Data.newBuilder().also { initFn(it) }.build() if (channel == DataPacket.PKC_CHANNEL_INDEX) { pkiEncrypted = true - nodeDBbyNodeNum[to]?.user?.publicKey?.let { this.publicKey = it } + nodeDBbyNodeNum[to]?.user?.publicKey?.let { publicKey -> this.publicKey = publicKey } } else { this.channel = channel } + return build() } + /** Helper to make it easy to build a subpacket in the proper protobufs */ private fun MeshPacket.Builder.buildAdminPacket( - id: Int = generatePacketId(), + id: Int = generatePacketId(), // always assign a packet ID if we didn't already have one wantResponse: Boolean = false, initFn: AdminProtos.AdminMessage.Builder.() -> Unit, ): MeshPacket = buildMeshPacket(id = id, wantAck = true, channel = adminChannelIndex, priority = MeshPacket.Priority.RELIABLE) { this.wantResponse = wantResponse - this.portnumValue = Portnums.PortNum.ADMIN_APP_VALUE - this.payload = + portnumValue = Portnums.PortNum.ADMIN_APP_VALUE + payload = AdminProtos.AdminMessage.newBuilder() - .apply { - initFn(this) - this.sessionPasskey = this@MeshService.sessionPasskey + .also { + initFn(it) + it.sessionPasskey = sessionPasskey } .build() .toByteString() } - private fun toDataPacket(packet: MeshPacket): DataPacket? { - if (!packet.hasDecoded()) return null + // Generate a DataPacket from a MeshPacket, or null if we didn't have enough data to do so + private fun toDataPacket(packet: MeshPacket): DataPacket? = if (!packet.hasDecoded()) { + // We never convert packets that are not DataPackets + null + } else { val data = packet.decoded - return DataPacket( + + DataPacket( from = toNodeID(packet.from), to = toNodeID(packet.to), time = packet.rxTime * 1000L, @@ -653,18 +691,20 @@ class MeshService : ) } - private fun toMeshPacket(dataPacket: DataPacket): MeshPacket = newMeshPacketTo(dataPacket.to!!).buildMeshPacket( - id = dataPacket.id, - wantAck = dataPacket.wantAck, - hopLimit = dataPacket.hopLimit, - channel = dataPacket.channel, + private fun toMeshPacket(p: DataPacket): MeshPacket = newMeshPacketTo(p.to!!).buildMeshPacket( + id = p.id, + wantAck = p.wantAck, + hopLimit = p.hopLimit, + channel = p.channel, ) { - portnumValue = dataPacket.dataType - payload = ByteString.copyFrom(dataPacket.bytes) - dataPacket.replyId?.takeIf { it != 0 }?.let { this.replyId = it } + portnumValue = p.dataType + payload = ByteString.copyFrom(p.bytes) + if (p.replyId != null && p.replyId != 0) { + this.replyId = p.replyId!! + } } - private val rememberableDataTypes = + private val rememberDataType = setOf( Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, Portnums.PortNum.ALERT_APP_VALUE, @@ -683,11 +723,12 @@ class MeshService : } private fun rememberDataPacket(dataPacket: DataPacket, updateNotification: Boolean = true) { - if (dataPacket.dataType !in rememberableDataTypes) return - + if (dataPacket.dataType !in rememberDataType) return val fromLocal = dataPacket.from == DataPacket.ID_LOCAL val toBroadcast = dataPacket.to == DataPacket.ID_BROADCAST val contactId = if (fromLocal || toBroadcast) dataPacket.to else dataPacket.from + + // contactKey: unique contact key filter (channel)+(nodeId) val contactKey = "${dataPacket.channel}$contactId" val packetToSave = @@ -718,172 +759,154 @@ class MeshService : } } - // region Received Data Handlers + // Update our model and resend as needed for a MeshPacket we just received from the radio private fun handleReceivedData(packet: MeshPacket) { - val currentMyNodeInfo = myNodeInfo ?: return // Early exit if no local node info + myNodeInfo?.let { myInfo -> + val data = packet.decoded + val bytes = data.payload.toByteArray() + val fromId = toNodeID(packet.from) + val dataPacket = toDataPacket(packet) - val decodedData = packet.decoded - val fromNodeId = toNodeID(packet.from) - val appDataPacket = toDataPacket(packet) ?: return // Not a processable data packet + if (dataPacket != null) { + // We ignore most messages that we sent + val fromUs = myInfo.myNodeNum == packet.from - val fromThisDevice = currentMyNodeInfo.myNodeNum == packet.from - debug("Received data from $fromNodeId, portnum=${decodedData.portnum} ${decodedData.payload.size()} bytes") - appDataPacket.status = MessageStatus.RECEIVED + debug("Received data from $fromId, portnum=${data.portnum} ${bytes.size} bytes") - var shouldBroadcastToClients = !fromThisDevice + dataPacket.status = MessageStatus.RECEIVED - when (decodedData.portnumValue) { - Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> handleReceivedText(packet, appDataPacket, fromNodeId) + // if (p.hasUser()) handleReceivedUser(fromNum, p.user) - Portnums.PortNum.ALERT_APP_VALUE -> handleReceivedAlert(appDataPacket, fromNodeId) - Portnums.PortNum.WAYPOINT_APP_VALUE -> handleReceivedWaypoint(packet, appDataPacket) - Portnums.PortNum.POSITION_APP_VALUE -> handleReceivedPositionApp(packet, decodedData, appDataPacket) + // We tell other apps about most message types, but some may have sensitive data, so + // that is not shared' + var shouldBroadcast = !fromUs - Portnums.PortNum.NODEINFO_APP_VALUE -> if (!fromThisDevice) handleReceivedNodeInfoApp(packet, decodedData) + when (data.portnumValue) { + Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> { + if (data.replyId != 0 && data.emoji == 0) { + debug("Received REPLY from $fromId") + rememberDataPacket(dataPacket) + } else if (data.replyId != 0 && data.emoji != 0) { + debug("Received EMOJI from $fromId") + rememberReaction(packet) + } else { + debug("Received CLEAR_TEXT from $fromId") + rememberDataPacket(dataPacket) + } + } - Portnums.PortNum.TELEMETRY_APP_VALUE -> handleReceivedTelemetryApp(packet, decodedData, appDataPacket) + Portnums.PortNum.ALERT_APP_VALUE -> { + debug("Received ALERT_APP from $fromId") + rememberDataPacket(dataPacket) + } - Portnums.PortNum.ROUTING_APP_VALUE -> { - shouldBroadcastToClients = true - handleReceivedRoutingApp(decodedData, fromNodeId) - } + Portnums.PortNum.WAYPOINT_APP_VALUE -> { + val u = MeshProtos.Waypoint.parseFrom(data.payload) + // Validate locked Waypoints from the original sender + if (u.lockedTo != 0 && u.lockedTo != packet.from) return + rememberDataPacket(dataPacket, u.expire > currentSecond()) + } - Portnums.PortNum.ADMIN_APP_VALUE -> { - handleReceivedAdmin(packet.from, AdminProtos.AdminMessage.parseFrom(decodedData.payload)) - shouldBroadcastToClients = false - } + Portnums.PortNum.POSITION_APP_VALUE -> { + val u = MeshProtos.Position.parseFrom(data.payload) + // debug("position_app ${packet.from} ${u.toOneLineString()}") + if (data.wantResponse && u.latitudeI == 0 && u.longitudeI == 0) { + debug("Ignoring nop position update from position request") + } else { + handleReceivedPosition(packet.from, u, dataPacket.time) + } + } - Portnums.PortNum.PAXCOUNTER_APP_VALUE -> { - handleReceivedPaxcounter(packet.from, PaxcountProtos.Paxcount.parseFrom(decodedData.payload)) - shouldBroadcastToClients = false - } + Portnums.PortNum.NODEINFO_APP_VALUE -> + if (!fromUs) { + val u = + MeshProtos.User.parseFrom(data.payload).copy { + if (isLicensed) clearPublicKey() + if (packet.viaMqtt) longName = "$longName (MQTT)" + } + handleReceivedUser(packet.from, u, packet.channel) + } - Portnums.PortNum.STORE_FORWARD_APP_VALUE -> { - handleReceivedStoreAndForward( - appDataPacket, - StoreAndForwardProtos.StoreAndForward.parseFrom(decodedData.payload), + // Handle new telemetry info + Portnums.PortNum.TELEMETRY_APP_VALUE -> { + val u = + TelemetryProtos.Telemetry.parseFrom(data.payload).copy { + if (time == 0) time = (dataPacket.time / 1000L).toInt() + } + handleReceivedTelemetry(packet.from, u) + } + + Portnums.PortNum.ROUTING_APP_VALUE -> { + // We always send ACKs to other apps, because they might care about the + // messages they sent + shouldBroadcast = true + val u = MeshProtos.Routing.parseFrom(data.payload) + + if (u.errorReason == MeshProtos.Routing.Error.DUTY_CYCLE_LIMIT) { + radioConfigRepository.setErrorMessage(getString(R.string.error_duty_cycle)) + } + + handleAckNak(data.requestId, fromId, u.errorReasonValue) + queueResponse.remove(data.requestId)?.complete(true) + } + + Portnums.PortNum.ADMIN_APP_VALUE -> { + val u = AdminProtos.AdminMessage.parseFrom(data.payload) + handleReceivedAdmin(packet.from, u) + shouldBroadcast = false + } + + Portnums.PortNum.PAXCOUNTER_APP_VALUE -> { + val p = PaxcountProtos.Paxcount.parseFrom(data.payload) + handleReceivedPaxcounter(packet.from, p) + shouldBroadcast = false + } + + Portnums.PortNum.STORE_FORWARD_APP_VALUE -> { + val u = StoreAndForwardProtos.StoreAndForward.parseFrom(data.payload) + handleReceivedStoreAndForward(dataPacket, u) + shouldBroadcast = false + } + + Portnums.PortNum.RANGE_TEST_APP_VALUE -> { + if (!moduleConfig.rangeTest.enabled) return + val u = dataPacket.copy(dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) + rememberDataPacket(u) + } + + Portnums.PortNum.DETECTION_SENSOR_APP_VALUE -> { + val u = dataPacket.copy(dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) + rememberDataPacket(u) + } + + Portnums.PortNum.TRACEROUTE_APP_VALUE -> { + radioConfigRepository.setTracerouteResponse(packet.getTracerouteResponse(::getUserName)) + } + + else -> debug("No custom processing needed for ${data.portnumValue}") + } + + // We always tell other apps when new data packets arrive + if (shouldBroadcast) { + serviceBroadcasts.broadcastReceivedData(dataPacket) + } + + GeeksvilleApplication.analytics.track("num_data_receive", DataPair(1)) + + GeeksvilleApplication.analytics.track( + "data_receive", + DataPair("num_bytes", bytes.size), + DataPair("type", data.portnumValue), ) - shouldBroadcastToClients = false - } - - Portnums.PortNum.RANGE_TEST_APP_VALUE -> handleReceivedRangeTest(appDataPacket) - Portnums.PortNum.DETECTION_SENSOR_APP_VALUE -> handleReceivedDetectionSensor(appDataPacket) - - Portnums.PortNum.TRACEROUTE_APP_VALUE -> - radioConfigRepository.setTracerouteResponse(packet.getTracerouteResponse(::getUserName)) - - else -> debug("No custom processing needed for ${decodedData.portnumValue}") - } - - if (shouldBroadcastToClients) { - serviceBroadcasts.broadcastReceivedData(appDataPacket) - } - trackDataReceptionAnalytics(decodedData.portnumValue, decodedData.payload.size()) - } - - private fun handleReceivedText(meshPacket: MeshPacket, dataPacket: DataPacket, fromId: String) { - val decodedPayload = meshPacket.decoded - when { - decodedPayload.replyId != 0 && decodedPayload.emoji == 0 -> { // Text reply - debug("Received REPLY from $fromId") - rememberDataPacket(dataPacket) - } - - decodedPayload.replyId != 0 && decodedPayload.emoji != 0 -> { // Emoji reaction - debug("Received EMOJI from $fromId") - rememberReaction(meshPacket) - } - - else -> { // Standard text message - debug("Received CLEAR_TEXT from $fromId") - rememberDataPacket(dataPacket) } } } - private fun handleReceivedAlert(dataPacket: DataPacket, fromId: String) { - debug("Received ALERT_APP from $fromId") - rememberDataPacket(dataPacket) - } - - private fun handleReceivedWaypoint(meshPacket: MeshPacket, dataPacket: DataPacket) { - val waypointProto = MeshProtos.Waypoint.parseFrom(meshPacket.decoded.payload) - // Validate locked Waypoints from the original sender - if (waypointProto.lockedTo != 0 && waypointProto.lockedTo != meshPacket.from) return - rememberDataPacket(dataPacket, waypointProto.expire > currentSecond()) - } - - private fun handleReceivedPositionApp( - meshPacket: MeshPacket, - decodedData: MeshProtos.Data, - dataPacket: DataPacket, - ) { - val positionProto = MeshProtos.Position.parseFrom(decodedData.payload) - if (decodedData.wantResponse && positionProto.latitudeI == 0 && positionProto.longitudeI == 0) { - debug("Ignoring nop position update from position request") - } else { - handleReceivedPosition(meshPacket.from, positionProto, dataPacket.time) - } - } - - private fun handleReceivedNodeInfoApp(meshPacket: MeshPacket, decodedData: MeshProtos.Data) { - val userProto = - MeshProtos.User.parseFrom(decodedData.payload).copy { - if (isLicensed) clearPublicKey() - if (meshPacket.viaMqtt) longName = "$longName (MQTT)" - } - handleReceivedUser(meshPacket.from, userProto, meshPacket.channel) - } - - private fun handleReceivedTelemetryApp( - meshPacket: MeshPacket, - decodedData: MeshProtos.Data, - dataPacket: DataPacket, - ) { - val telemetryProto = - TelemetryProtos.Telemetry.parseFrom(decodedData.payload).copy { - if (time == 0) time = (dataPacket.time / 1000L).toInt() - } - handleReceivedTelemetry(meshPacket.from, telemetryProto) - } - - private fun handleReceivedRoutingApp(decodedData: MeshProtos.Data, fromId: String) { - val routingProto = MeshProtos.Routing.parseFrom(decodedData.payload) - if (routingProto.errorReason == MeshProtos.Routing.Error.DUTY_CYCLE_LIMIT) { - radioConfigRepository.setErrorMessage(getString(R.string.error_duty_cycle)) - } - handleAckNak(decodedData.requestId, fromId, routingProto.errorReasonValue) - queueResponse.remove(decodedData.requestId)?.complete(true) - } - - private fun handleReceivedRangeTest(dataPacket: DataPacket) { - if (!moduleConfig.rangeTest.enabled) return - val textDataPacket = dataPacket.copy(dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) - rememberDataPacket(textDataPacket) - } - - private fun handleReceivedDetectionSensor(dataPacket: DataPacket) { - val textDataPacket = dataPacket.copy(dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) - rememberDataPacket(textDataPacket) - } - - private fun trackDataReceptionAnalytics(portNum: Int, bytesSize: Int) { - GeeksvilleApplication.analytics.track("num_data_receive", DataPair(1)) - GeeksvilleApplication.analytics.track( - "data_receive", - DataPair("num_bytes", bytesSize), - DataPair("type", portNum), - ) - } - - // endregion - - @Suppress("NestedBlockDepth") - private fun handleReceivedAdmin(fromNodeNum: Int, adminMessage: AdminProtos.AdminMessage) { - when (adminMessage.payloadVariantCase) { + private fun handleReceivedAdmin(fromNodeNum: Int, a: AdminProtos.AdminMessage) { + when (a.payloadVariantCase) { AdminProtos.AdminMessage.PayloadVariantCase.GET_CONFIG_RESPONSE -> { if (fromNodeNum == myNodeNum) { - val response = adminMessage.getConfigResponse + val response = a.getConfigResponse debug("Admin: received config ${response.payloadVariantCase}") setLocalConfig(response) } @@ -891,10 +914,12 @@ class MeshService : AdminProtos.AdminMessage.PayloadVariantCase.GET_CHANNEL_RESPONSE -> { if (fromNodeNum == myNodeNum) { - myNodeInfo?.let { - val ch = adminMessage.getChannelResponse + val mi = myNodeInfo + if (mi != null) { + val ch = a.getChannelResponse debug("Admin: Received channel ${ch.index}") - if (ch.index + 1 < it.maxChannels) { + + if (ch.index + 1 < mi.maxChannels) { handleChannel(ch) } } @@ -904,351 +929,376 @@ class MeshService : AdminProtos.AdminMessage.PayloadVariantCase.GET_DEVICE_METADATA_RESPONSE -> { debug("Admin: received DeviceMetadata from $fromNodeNum") serviceScope.handledLaunch { - radioConfigRepository.insertMetadata(fromNodeNum, adminMessage.getDeviceMetadataResponse) + radioConfigRepository.insertMetadata(fromNodeNum, a.getDeviceMetadataResponse) } } - AdminProtos.AdminMessage.PayloadVariantCase.PAYLOADVARIANT_NOT_SET, - null, - -> warn("Received admin message with no payload variant set.") - - else -> warn("No special processing needed for admin payload ${adminMessage.payloadVariantCase}") + else -> warn("No special processing needed for ${a.payloadVariantCase}") } debug("Admin: Received session_passkey from $fromNodeNum") - sessionPasskey = adminMessage.sessionPasskey + sessionPasskey = a.sessionPasskey } - private fun handleReceivedUser(fromNum: Int, userProto: MeshProtos.User, channel: Int = 0) { - updateNodeInfo(fromNum, channel = channel) { nodeEntity -> - val isNewNode = (nodeEntity.isUnknownUser && userProto.hwModel != MeshProtos.HardwareModel.UNSET) - val keyMatch = !nodeEntity.hasPKC || nodeEntity.user.publicKey == userProto.publicKey + // Update our DB of users based on someone sending out a User subpacket + private fun handleReceivedUser(fromNum: Int, p: MeshProtos.User, channel: Int = 0) { + updateNodeInfo(fromNum) { + val newNode = (it.isUnknownUser && p.hwModel != MeshProtos.HardwareModel.UNSET) - nodeEntity.user = + val keyMatch = !it.hasPKC || it.user.publicKey == p.publicKey + it.user = if (keyMatch) { - userProto + p } else { - userProto.copy { - warn("Public key mismatch from ${userProto.longName} (${userProto.shortName})") + p.copy { + warn("Public key mismatch from $longName ($shortName)") publicKey = NodeEntity.ERROR_BYTE_STRING } } - nodeEntity.longName = userProto.longName - nodeEntity.shortName = userProto.shortName - if (isNewNode) { - serviceNotifications.showNewNodeSeenNotification(nodeEntity) + it.longName = p.longName + it.shortName = p.shortName + it.channel = channel + if (newNode) { + serviceNotifications.showNewNodeSeenNotification(it) } } } + /** + * Update our DB of users based on someone sending out a Position subpacket + * + * @param defaultTime in msecs since 1970 + */ private fun handleReceivedPosition( fromNum: Int, - positionProto: MeshProtos.Position, - defaultTimeMillis: Long = System.currentTimeMillis(), + p: MeshProtos.Position, + defaultTime: Long = System.currentTimeMillis(), ) { - if (myNodeNum == fromNum && positionProto.latitudeI == 0 && positionProto.longitudeI == 0) { + // Nodes periodically send out position updates, but those updates might not contain a lat & + // lon (because no GPS + // lock) + // We like to look at the local node to see if it has been sending out valid lat/lon, so for + // the LOCAL node + // (only) + // we don't record these nop position updates + if (myNodeNum == fromNum && p.latitudeI == 0 && p.longitudeI == 0) { debug("Ignoring nop position update for the local node") - return - } - updateNodeInfo(fromNum) { - debug("update position: ${it.longName?.toPIIString()} with ${positionProto.toPIIString()}") - it.setPosition(positionProto, (defaultTimeMillis / 1000L).toInt()) + } else { + updateNodeInfo(fromNum) { + debug("update position: ${it.longName?.toPIIString()} with ${p.toPIIString()}") + it.setPosition(p, (defaultTime / 1000L).toInt()) + } } } - private fun handleReceivedTelemetry(fromNum: Int, telemetryProto: TelemetryProtos.Telemetry) { + // Update our DB of users based on someone sending out a Telemetry subpacket + private fun handleReceivedTelemetry(fromNum: Int, t: TelemetryProtos.Telemetry) { val isRemote = (fromNum != myNodeNum) - if (!isRemote && telemetryProto.hasLocalStats()) { - localStatsTelemetry = telemetryProto + if (!isRemote && t.hasLocalStats()) { + localStatsTelemetry = t maybeUpdateServiceStatusNotification() } - updateNodeInfo(fromNum) { nodeEntity -> + updateNodeInfo(fromNum) { when { - telemetryProto.hasDeviceMetrics() -> { - nodeEntity.deviceTelemetry = telemetryProto - if (fromNum == myNodeNum || (isRemote && nodeEntity.isFavorite)) { - val metrics = telemetryProto.deviceMetrics + t.hasDeviceMetrics() -> { + it.deviceTelemetry = t + if (fromNum == myNodeNum || (isRemote && it.isFavorite)) { if ( - metrics.voltage > batteryPercentUnsupported && - metrics.batteryLevel <= batteryPercentLowThreshold + t.deviceMetrics.voltage > batteryPercentUnsupported && + t.deviceMetrics.batteryLevel <= batteryPercentLowThreshold ) { - if (shouldBatteryNotificationShow(fromNum, telemetryProto)) { - serviceNotifications.showOrUpdateLowBatteryNotification(nodeEntity, isRemote) + if (shouldBatteryNotificationShow(fromNum, t)) { + serviceNotifications.showOrUpdateLowBatteryNotification(it, isRemote) } } else { - batteryPercentCooldowns.remove(fromNum) - serviceNotifications.cancelLowBatteryNotification(nodeEntity) + if (batteryPercentCooldowns.containsKey(fromNum)) { + batteryPercentCooldowns.remove(fromNum) + } + serviceNotifications.cancelLowBatteryNotification(it) } } } - telemetryProto.hasEnvironmentMetrics() -> nodeEntity.environmentTelemetry = telemetryProto - - telemetryProto.hasPowerMetrics() -> nodeEntity.powerTelemetry = telemetryProto + t.hasEnvironmentMetrics() -> it.environmentTelemetry = t + t.hasPowerMetrics() -> it.powerTelemetry = t } } } - private fun shouldBatteryNotificationShow(fromNum: Int, telemetry: TelemetryProtos.Telemetry): Boolean { + private fun shouldBatteryNotificationShow(fromNum: Int, t: TelemetryProtos.Telemetry): Boolean { val isRemote = (fromNum != myNodeNum) - val batteryLevel = telemetry.deviceMetrics.batteryLevel var shouldDisplay = false var forceDisplay = false - when { - batteryLevel <= batteryPercentCriticalThreshold -> { + t.deviceMetrics.batteryLevel <= batteryPercentCriticalThreshold -> { shouldDisplay = true forceDisplay = true } - batteryLevel == batteryPercentLowThreshold -> shouldDisplay = true - batteryLevel % batteryPercentLowDivisor == 0 && !isRemote -> shouldDisplay = true - isRemote -> shouldDisplay = true // For remote favorites, show if low - } + t.deviceMetrics.batteryLevel == batteryPercentLowThreshold -> shouldDisplay = true + t.deviceMetrics.batteryLevel.mod(batteryPercentLowDivisor) == 0 && !isRemote -> shouldDisplay = true + isRemote -> shouldDisplay = true + } if (shouldDisplay) { - val nowSeconds = System.currentTimeMillis() / 1000 - val lastNotificationTime = batteryPercentCooldowns[fromNum] ?: 0L - if ((nowSeconds - lastNotificationTime) >= batteryPercentCooldownSeconds || forceDisplay) { - batteryPercentCooldowns[fromNum] = nowSeconds + val now = System.currentTimeMillis() / 1000 + if (!batteryPercentCooldowns.containsKey(fromNum)) batteryPercentCooldowns[fromNum] = 0 + if ((now - batteryPercentCooldowns[fromNum]!!) >= batteryPercentCooldownSeconds || forceDisplay) { + batteryPercentCooldowns[fromNum] = now return true } } return false } - private fun handleReceivedPaxcounter(fromNum: Int, paxcountProto: PaxcountProtos.Paxcount) { - updateNodeInfo(fromNum) { it.paxcounter = paxcountProto } + private fun handleReceivedPaxcounter(fromNum: Int, p: PaxcountProtos.Paxcount) { + updateNodeInfo(fromNum) { it.paxcounter = p } } - private fun handleReceivedStoreAndForward( - dataPacket: DataPacket, - storeAndForwardProto: StoreAndForwardProtos.StoreAndForward, - ) { - debug("StoreAndForward: ${storeAndForwardProto.variantCase} ${storeAndForwardProto.rr} from ${dataPacket.from}") - when (storeAndForwardProto.variantCase) { + private fun handleReceivedStoreAndForward(dataPacket: DataPacket, s: StoreAndForwardProtos.StoreAndForward) { + debug("StoreAndForward: ${s.variantCase} ${s.rr} from ${dataPacket.from}") + when (s.variantCase) { StoreAndForwardProtos.StoreAndForward.VariantCase.STATS -> { - val textPacket = + val u = dataPacket.copy( - bytes = storeAndForwardProto.stats.toString().encodeToByteArray(), + bytes = s.stats.toString().encodeToByteArray(), dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, ) - rememberDataPacket(textPacket) + rememberDataPacket(u) } StoreAndForwardProtos.StoreAndForward.VariantCase.HISTORY -> { val text = """ - Total messages: ${storeAndForwardProto.history.historyMessages} - History window: ${storeAndForwardProto.history.window / 60000} min - Last request: ${storeAndForwardProto.history.lastRequest} + Total messages: ${s.history.historyMessages} + History window: ${s.history.window / 60000} min + Last request: ${s.history.lastRequest} """ .trimIndent() - val textPacket = + val u = dataPacket.copy( bytes = text.encodeToByteArray(), dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, ) - rememberDataPacket(textPacket) + rememberDataPacket(u) } StoreAndForwardProtos.StoreAndForward.VariantCase.TEXT -> { - var actualTo = dataPacket.to - if ( - storeAndForwardProto.rr == - StoreAndForwardProtos.StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST - ) { - actualTo = DataPacket.ID_BROADCAST + if (s.rr == StoreAndForwardProtos.StoreAndForward.RequestResponse.ROUTER_TEXT_BROADCAST) { + dataPacket.to = DataPacket.ID_BROADCAST } - val textPacket = - dataPacket.copy( - to = actualTo, - bytes = storeAndForwardProto.text.toByteArray(), - dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, - ) - rememberDataPacket(textPacket) + val u = + dataPacket.copy(bytes = s.text.toByteArray(), dataType = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) + rememberDataPacket(u) } - StoreAndForwardProtos.StoreAndForward.VariantCase.VARIANT_NOT_SET, - null, - -> Unit - - StoreAndForwardProtos.StoreAndForward.VariantCase.HEARTBEAT -> {} + else -> {} } } - private val offlineSentPackets = ConcurrentLinkedQueue() + // If apps try to send packets when our radio is sleeping, we queue them here instead + private val offlineSentPackets = mutableListOf() + // Update our model and resend as needed for a MeshPacket we just received from the radio private fun handleReceivedMeshPacket(packet: MeshPacket) { - val processedPacket = - packet - .toBuilder() - .apply { - if (rxTime == 0) setRxTime(currentSecond()) // Ensure rxTime is set - } - .build() - processReceivedMeshPacketInternal(processedPacket) - onNodeDBChanged() + if (haveNodeDB) { + processReceivedMeshPacket( + packet + .toBuilder() + .apply { + // If the rxTime was not set by the device, update with current time + if (packet.rxTime == 0) setRxTime(currentSecond()) + } + .build(), + ) + onNodeDBChanged() + } else { + warn("Ignoring early received packet: ${packet.toOneLineString()}") + // earlyReceivedPackets.add(packet) + // logAssert(earlyReceivedPackets.size < 128) // The max should normally be about 32, + // but if the device is + // messed up it might try to send forever + } } private val queuedPackets = ConcurrentLinkedQueue() - private val queueResponse = ConcurrentHashMap>() - private val queueJobLock = Any() + private val queueResponse = mutableMapOf>() private var queueJob: Job? = null private fun sendPacket(packet: MeshPacket): CompletableFuture { + // send the packet to the radio and return a CompletableFuture that will be completed with + // the result val future = CompletableFuture() queueResponse[packet.id] = future try { - if (connectionRouter.connectionState.value != ConnectionState.CONNECTED) { - throw RadioNotConnectedException("Cannot send packet, radio not connected.") - } - sendToRadio(ToRadio.newBuilder().setPacket(packet)) + if (connectionState != ConnectionState.CONNECTED) throw RadioNotConnectedException() + sendToRadio(ToRadio.newBuilder().apply { this.packet = packet }) } catch (ex: Exception) { errormsg("sendToRadio error:", ex) - queueResponse.remove(packet.id) // Clean up if send failed immediately - future.completeExceptionally(ex) // Complete with exception + future.complete(false) } return future } private fun startPacketQueue() { - synchronized(queueJobLock) { - if (queueJob?.isActive == true) return - queueJob = - serviceScope.handledLaunch { - debug("Packet queueJob started") - while ( - connectionRouter.connectionState.value == ConnectionState.CONNECTED && - queuedPackets.isNotEmpty() - ) { - val packet = queuedPackets.poll() ?: break // Should not be null if loop condition met - try { - debug("Queue: Sending packet id=${packet.id.toUInt()}") - val success = sendPacket(packet).get(2, TimeUnit.MINUTES) - debug("Queue: Packet id=${packet.id.toUInt()} sent, success=$success") - } catch (e: TimeoutException) { - debug("Queue: Packet id=${packet.id.toUInt()} timed out: ${e.message}") - queueResponse.remove(packet.id)?.complete(false) - } catch (e: Exception) { - debug("Queue: Packet id=${packet.id.toUInt()} failed: ${e.message}") - queueResponse.remove(packet.id)?.complete(false) - } + if (queueJob?.isActive == true) return + queueJob = + serviceScope.handledLaunch { + debug("packet queueJob started") + while (connectionState == ConnectionState.CONNECTED) { + // take the first packet from the queue head + val packet = queuedPackets.poll() ?: break + try { + // send packet to the radio and wait for response + val response = sendPacket(packet) + debug("queueJob packet id=${packet.id.toUInt()} waiting") + val success = response.get(2, TimeUnit.MINUTES) + debug("queueJob packet id=${packet.id.toUInt()} success $success") + } catch (e: TimeoutException) { + debug("queueJob packet id=${packet.id.toUInt()} timeout") + } catch (e: Exception) { + debug("queueJob packet id=${packet.id.toUInt()} failed") } - debug("Packet queueJob finished or radio disconnected") } - } + } } private fun stopPacketQueue() { - synchronized(queueJobLock) { - queueJob - ?.takeIf { it.isActive } - ?.let { - info("Stopping packet queueJob") - it.cancel() - queueJob = null - queuedPackets.clear() - queueResponse.values.forEach { future -> if (!future.isDone) future.complete(false) } - queueResponse.clear() - } + if (queueJob?.isActive == true) { + info("Stopping packet queueJob") + queueJob?.cancel() + queueJob = null + queuedPackets.clear() + queueResponse.entries.lastOrNull { !it.value.isDone }?.value?.complete(false) + queueResponse.clear() } } - private fun sendNow(dataPacket: DataPacket) { - val meshPacket = toMeshPacket(dataPacket) - dataPacket.time = System.currentTimeMillis() // Update time to actual send time - sendToRadio(meshPacket) + private fun sendNow(p: DataPacket) { + val packet = toMeshPacket(p) + p.time = System.currentTimeMillis() // update time to the actual time we started sending + // debug("Sending to radio: ${packet.toPIIString()}") + sendToRadio(packet) } private fun processQueuedPackets() { - while (offlineSentPackets.isNotEmpty()) { - val p = offlineSentPackets.poll() ?: continue + val sentPackets = mutableListOf() + offlineSentPackets.forEach { p -> try { sendNow(p) + sentPackets.add(p) } catch (ex: Exception) { - errormsg("Error sending queued message, re-queuing:", ex) - offlineSentPackets.add(p) // Re-queue if sending failed + errormsg("Error sending queued message:", ex) } } + offlineSentPackets.removeAll(sentPackets) } - private suspend fun getDataPacketById(packetId: Int): DataPacket? = withTimeoutOrNull(1000L) { + private suspend fun getDataPacketById(packetId: Int): DataPacket? = withTimeoutOrNull(1000) { var dataPacket: DataPacket? = null - while (dataPacket == null && isActive) { // check coroutine isActive + while (dataPacket == null) { dataPacket = packetRepository.get().getPacketById(packetId)?.data - if (dataPacket == null) delay(100L) + if (dataPacket == null) delay(100) } dataPacket } - private fun changeStatus(packetId: Int, status: MessageStatus) = serviceScope.handledLaunch { - if (packetId == 0) return@handledLaunch // Ignore packets with no ID - - getDataPacketById(packetId)?.let { p -> - if (p.status == status) return@handledLaunch - packetRepository.get().updateMessageStatus(p, status) - serviceBroadcasts.broadcastMessageStatus(packetId, status) - } - } - - private fun handleAckNak(requestId: Int, fromId: String, routingError: Int) { - serviceScope.handledLaunch { - val isAck = routingError == MeshProtos.Routing.Error.NONE_VALUE - val packetEntity = packetRepository.get().getPacketById(requestId) - - packetEntity?.data?.let { dataPacket -> - // Distinguish real ACKs coming from the intended receiver - val newStatus = - when { - isAck && fromId == dataPacket.to -> MessageStatus.RECEIVED - isAck -> MessageStatus.DELIVERED - else -> MessageStatus.ERROR - } - if (dataPacket.status != MessageStatus.RECEIVED) { // Don't override final RECEIVED - dataPacket.status = newStatus - packetRepository.get().update(packetEntity.copy(routingError = routingError, data = dataPacket)) - } - serviceBroadcasts.broadcastMessageStatus(requestId, newStatus) + /** Change the status on a DataPacket and update watchers */ + private fun changeStatus(packetId: Int, m: MessageStatus) = serviceScope.handledLaunch { + if (packetId != 0) { + getDataPacketById(packetId)?.let { p -> + if (p.status == m) return@handledLaunch + packetRepository.get().updateMessageStatus(p, m) + serviceBroadcasts.broadcastMessageStatus(packetId, m) } } } - private fun processReceivedMeshPacketInternal(packet: MeshPacket) { - if (!packet.hasDecoded()) return - - insertMeshLog( - MeshLog( - uuid = UUID.randomUUID().toString(), - message_type = "PacketReceived", // Clarified type - received_date = System.currentTimeMillis(), - raw_message = packet.toString(), - fromNum = packet.from, - portNum = packet.decoded.portnumValue, - fromRadio = fromRadio { this.packet = packet }, - ), - ) - serviceScope.handledLaunch { radioConfigRepository.emitMeshPacket(packet) } - - val isOtherNode = myNodeNum != packet.from - // Update our own node's lastHeard as we are clearly active to receive this - updateNodeInfo(myNodeNum, withBroadcast = isOtherNode) { it.lastHeard = currentSecond() } - - updateNodeInfo(packet.from, withBroadcast = false, channel = packet.channel) { - it.lastHeard = packet.rxTime - it.snr = packet.rxSnr - it.rssi = packet.rxRssi - it.hopsAway = - if (packet.hopStart == 0 || packet.hopLimit > packet.hopStart) { - -1 // Unknown or direct - } else { - packet.hopStart - packet.hopLimit + /** Handle an ack/nak packet by updating sent message status */ + private fun handleAckNak(requestId: Int, fromId: String, routingError: Int) { + serviceScope.handledLaunch { + val isAck = routingError == MeshProtos.Routing.Error.NONE_VALUE + val p = packetRepository.get().getPacketById(requestId) + // distinguish real ACKs coming from the intended receiver + val m = + when { + isAck && fromId == p?.data?.to -> MessageStatus.RECEIVED + isAck -> MessageStatus.DELIVERED + else -> MessageStatus.ERROR } + if (p != null && p.data.status != MessageStatus.RECEIVED) { + p.data.status = m + p.routingError = routingError + packetRepository.get().update(p) + } + serviceBroadcasts.broadcastMessageStatus(requestId, m) } - handleReceivedData(packet) } - private fun insertMeshLog(meshLog: MeshLog) { - serviceScope.handledLaunch { meshLogRepository.get().insert(meshLog) } + // Update our model and resend as needed for a MeshPacket we just received from the radio + private fun processReceivedMeshPacket(packet: MeshPacket) { + val fromNum = packet.from + + // FIXME, perhaps we could learn our node ID by looking at any to packets the radio + // decided to pass through to us (except for broadcast packets) + // val toNum = packet.to + + // debug("Received: $packet") + if (packet.hasDecoded()) { + val packetToSave = + MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "Packet", + received_date = System.currentTimeMillis(), + raw_message = packet.toString(), + fromNum = packet.from, + portNum = packet.decoded.portnumValue, + fromRadio = fromRadio { this.packet = packet }, + ) + insertMeshLog(packetToSave) + + serviceScope.handledLaunch { radioConfigRepository.emitMeshPacket(packet) } + + // Update last seen for the node that sent the packet, but also for _our node_ because + // anytime a packet + // passes + // through our node on the way to the phone that means that local node is also alive in + // the mesh + + val isOtherNode = myNodeNum != fromNum + updateNodeInfo(myNodeNum, withBroadcast = isOtherNode) { it.lastHeard = currentSecond() } + + // Do not generate redundant broadcasts of node change for this bookkeeping + // updateNodeInfo call + // because apps really only care about important updates of node state - which + // handledReceivedData will give + // them + updateNodeInfo(fromNum, withBroadcast = false, channel = packet.channel) { + // Update our last seen based on any valid timestamps. If the device didn't provide + // a timestamp make + // one + it.lastHeard = packet.rxTime + it.snr = packet.rxSnr + it.rssi = packet.rxRssi + + // Generate our own hopsAway, comparing hopStart to hopLimit. + it.hopsAway = + if (packet.hopStart == 0 || packet.hopLimit > packet.hopStart) { + -1 + } else { + packet.hopStart - packet.hopLimit + } + } + handleReceivedData(packet) + } + } + + private fun insertMeshLog(packetToSave: MeshLog) { + serviceScope.handledLaunch { + // Do not log, because might contain PII + // info("insert: ${packetToSave.message_type} = + // ${packetToSave.raw_message.toOneLineString()}") + meshLogRepository.get().insert(packetToSave) + } } private fun setLocalConfig(config: ConfigProtos.Config) { @@ -1259,15 +1309,17 @@ class MeshService : serviceScope.handledLaunch { radioConfigRepository.setLocalModuleConfig(config) } } - private fun updateChannelSettings(channel: ChannelProtos.Channel) = - serviceScope.handledLaunch { radioConfigRepository.updateChannelSettings(channel) } + private fun updateChannelSettings(ch: ChannelProtos.Channel) = + serviceScope.handledLaunch { radioConfigRepository.updateChannelSettings(ch) } private fun currentSecond() = (System.currentTimeMillis() / 1000).toInt() + // If we just changed our nodedb, we might want to do somethings private fun onNodeDBChanged() { maybeUpdateServiceStatusNotification() } + /** Send in analytics about mesh connection */ private fun reportConnection() { val radioModel = DataPair("radio_model", myNodeInfo?.model ?: "unknown") GeeksvilleApplication.analytics.track( @@ -1276,67 +1328,130 @@ class MeshService : DataPair("num_online", numOnlineNodes), radioModel, ) + + // Once someone connects to hardware start tracking the approximate number of nodes in their + // mesh + // this allows us to collect stats on what typical mesh size is and to tell difference + // between users who just + // downloaded the app, vs has connected it to some hardware. GeeksvilleApplication.analytics.setUserInfo(DataPair("num_nodes", numNodes), radioModel) } + private var sleepTimeout: Job? = null + + // msecs since 1970 we started this connection private var connectTimeMsec = 0L - private fun startConnect() { - try { - connectTimeMsec = System.currentTimeMillis() - sendConfigOnlyRequest() - } catch (ex: Exception) { - when (ex) { - is InvalidProtocolBufferException, - is RadioNotConnectedException, - is RemoteException, - -> { - errormsg("Failed to start connection sequence: ${ex.message}", ex) + // Called when we gain/lose connection to our radio + private fun onConnectionChanged(c: ConnectionState) { + debug("onConnectionChanged: $connectionState -> $c") + + // Perform all the steps needed once we start waiting for device sleep to complete + fun startDeviceSleep() { + stopPacketQueue() + stopLocationRequests() + stopMqttClientProxy() + + if (connectTimeMsec != 0L) { + val now = System.currentTimeMillis() + connectTimeMsec = 0L + + GeeksvilleApplication.analytics.track("connected_seconds", DataPair((now - connectTimeMsec) / 1000.0)) + } + + // Have our timeout fire in the appropriate number of seconds + sleepTimeout = + serviceScope.handledLaunch { + try { + // If we have a valid timeout, wait that long (+30 seconds) otherwise, just + // wait 30 seconds + val timeout = (localConfig.power?.lsSecs ?: 0) + 30 + + debug("Waiting for sleeping device, timeout=$timeout secs") + delay(timeout * 1000L) + warn("Device timeout out, setting disconnected") + onConnectionChanged(ConnectionState.DISCONNECTED) + } catch (ex: CancellationException) { + debug("device sleep timeout cancelled") + } } - else -> throw ex + // broadcast an intent with our new connection state + serviceBroadcasts.broadcastConnection() + } + + fun startDisconnect() { + stopPacketQueue() + stopLocationRequests() + stopMqttClientProxy() + + GeeksvilleApplication.analytics.track( + "mesh_disconnect", + DataPair("num_nodes", numNodes), + DataPair("num_online", numOnlineNodes), + ) + GeeksvilleApplication.analytics.track("num_nodes", DataPair(numNodes)) + + // broadcast an intent with our new connection state + serviceBroadcasts.broadcastConnection() + } + + fun startConnect() { + // Do our startup init + try { + connectTimeMsec = System.currentTimeMillis() + startConfigOnly() + } catch (ex: InvalidProtocolBufferException) { + errormsg("Invalid protocol buffer sent by device - update device software and try again", ex) + } catch (ex: RadioNotConnectedException) { + // note: no need to call startDeviceSleep(), because this exception could only have + // reached us if it was + // already called + errormsg("Lost connection to radio during init - waiting for reconnect ${ex.message}") + } catch (ex: RemoteException) { + // It seems that when the ESP32 goes offline it can briefly come back for a 100ms + // ish which + // causes the phone to try and reconnect. If we fail downloading our initial radio + // state we don't want + // to + // claim we have a valid connection still + connectionState = ConnectionState.DEVICE_SLEEP + startDeviceSleep() + throw ex // Important to rethrow so that we don't tell the app all is well } } - } - private fun startDeviceSleep() { - stopPacketQueue() - stopLocationRequests() - stopMqttClientProxy() - - if (connectTimeMsec != 0L) { - val now = System.currentTimeMillis() - GeeksvilleApplication.analytics.track("connected_seconds", DataPair((now - connectTimeMsec) / 1000.0)) - connectTimeMsec = 0L + // Cancel any existing timeouts + sleepTimeout?.let { + it.cancel() + sleepTimeout = null } - serviceBroadcasts.broadcastConnection() - } - private fun startDisconnect() { - stopPacketQueue() - stopLocationRequests() - stopMqttClientProxy() + connectionState = c + when (c) { + ConnectionState.CONNECTED -> startConnect() + ConnectionState.DEVICE_SLEEP -> startDeviceSleep() + ConnectionState.DISCONNECTED -> startDisconnect() + } - GeeksvilleApplication.analytics.track( - "mesh_disconnect", - DataPair("num_nodes", numNodes), - DataPair("num_online", numOnlineNodes), - ) - GeeksvilleApplication.analytics.track("num_nodes", DataPair(numNodes)) - serviceBroadcasts.broadcastConnection() + // Update the android notification in the status bar + maybeUpdateServiceStatusNotification() } private fun maybeUpdateServiceStatusNotification() { + var update = false val currentSummary = notificationSummary val currentStats = localStats val currentStatsUpdatedAtMillis = localStatsUpdatedAtMillis - - val summaryChanged = currentSummary.isNotBlank() && previousSummary != currentSummary - val statsChanged = currentStats != null && previousStats != currentStats - - if (summaryChanged || statsChanged) { + if (currentSummary.isNotBlank() && (previousSummary == null || !previousSummary.equals(currentSummary))) { previousSummary = currentSummary + update = true + } + if (currentStats != null && (previousStats == null || !(previousStats?.equals(currentStats) ?: false))) { previousStats = currentStats + update = true + } + if (update) { serviceNotifications.updateServiceStateNotification( summaryString = currentSummary, localStats = currentStats, @@ -1345,56 +1460,93 @@ class MeshService : } } - @SuppressLint("CheckResult") - @Suppress("CyclomaticComplexMethod") + private fun onRadioConnectionState(state: RadioServiceConnectionState) { + // sleep now disabled by default on ESP32, permanent is true unless light sleep enabled + val isRouter = localConfig.device.role == ConfigProtos.Config.DeviceConfig.Role.ROUTER + val lsEnabled = localConfig.power.isPowerSaving || isRouter + val connected = state.isConnected + val permanent = state.isPermanent || !lsEnabled + onConnectionChanged( + when { + connected -> ConnectionState.CONNECTED + permanent -> ConnectionState.DISCONNECTED + else -> ConnectionState.DEVICE_SLEEP + }, + ) + } + + private val packetHandlers: Map Unit)> by lazy { + PayloadVariantCase.entries.associateWith { variant: PayloadVariantCase -> + when (variant) { + PayloadVariantCase.PACKET -> { proto: MeshProtos.FromRadio -> handleReceivedMeshPacket(proto.packet) } + PayloadVariantCase.CONFIG_COMPLETE_ID -> { proto: MeshProtos.FromRadio -> + handleConfigComplete(proto.configCompleteId) + } + PayloadVariantCase.MY_INFO -> { proto: MeshProtos.FromRadio -> handleMyInfo(proto.myInfo) } + PayloadVariantCase.NODE_INFO -> { proto: MeshProtos.FromRadio -> handleNodeInfo(proto.nodeInfo) } + PayloadVariantCase.CHANNEL -> { proto: MeshProtos.FromRadio -> handleChannel(proto.channel) } + PayloadVariantCase.CONFIG -> { proto: MeshProtos.FromRadio -> handleDeviceConfig(proto.config) } + PayloadVariantCase.MODULECONFIG -> { proto: MeshProtos.FromRadio -> + handleModuleConfig(proto.moduleConfig) + } + PayloadVariantCase.QUEUESTATUS -> { proto: MeshProtos.FromRadio -> + handleQueueStatus(proto.queueStatus) + } + PayloadVariantCase.METADATA -> { proto: MeshProtos.FromRadio -> handleMetadata(proto.metadata) } + PayloadVariantCase.MQTTCLIENTPROXYMESSAGE -> { proto: MeshProtos.FromRadio -> + handleMqttProxyMessage(proto.mqttClientProxyMessage) + } + PayloadVariantCase.DEVICEUICONFIG -> { proto: MeshProtos.FromRadio -> + handleDeviceUiConfig(proto.deviceuiConfig) + } + PayloadVariantCase.FILEINFO -> { proto: MeshProtos.FromRadio -> handleFileInfo(proto.fileInfo) } + PayloadVariantCase.CLIENTNOTIFICATION -> { proto: MeshProtos.FromRadio -> + handleClientNotification(proto.clientNotification) + } + PayloadVariantCase.LOG_RECORD -> { proto: MeshProtos.FromRadio -> handleLogReord(proto.logRecord) } + PayloadVariantCase.REBOOTED -> { proto: MeshProtos.FromRadio -> handleRebooted(proto.rebooted) } + PayloadVariantCase.XMODEMPACKET -> { proto: MeshProtos.FromRadio -> + handleXmodemPacket(proto.xmodemPacket) + } + + // Explicitly handle default/unwanted cases to satisfy the exhaustive `when` + PayloadVariantCase.PAYLOADVARIANT_NOT_SET -> { proto -> + errormsg("Unexpected or unrecognized FromRadio variant: ${proto.payloadVariantCase}") + } + } + } + } + + private fun MeshProtos.FromRadio.route() { + packetHandlers[this.payloadVariantCase]?.invoke(this) + } + private fun onReceiveFromRadio(bytes: ByteArray) { try { val proto = MeshProtos.FromRadio.parseFrom(bytes) - when (proto.payloadVariantCase) { - MeshProtos.FromRadio.PayloadVariantCase.PACKET -> handleReceivedMeshPacket(proto.packet) - MeshProtos.FromRadio.PayloadVariantCase.CONFIG_COMPLETE_ID -> - handleConfigComplete(proto.configCompleteId) - - MeshProtos.FromRadio.PayloadVariantCase.MY_INFO -> handleMyInfo(proto.myInfo) - MeshProtos.FromRadio.PayloadVariantCase.NODE_INFO -> handleNodeInfo(proto.nodeInfo) - MeshProtos.FromRadio.PayloadVariantCase.CHANNEL -> handleChannel(proto.channel) - MeshProtos.FromRadio.PayloadVariantCase.CONFIG -> handleDeviceConfig(proto.config) - MeshProtos.FromRadio.PayloadVariantCase.MODULECONFIG -> handleModuleConfig(proto.moduleConfig) - MeshProtos.FromRadio.PayloadVariantCase.QUEUESTATUS -> handleQueueStatus(proto.queueStatus) - MeshProtos.FromRadio.PayloadVariantCase.METADATA -> handleMetadata(proto.metadata) - MeshProtos.FromRadio.PayloadVariantCase.MQTTCLIENTPROXYMESSAGE -> - handleMqttProxyMessage(proto.mqttClientProxyMessage) - - MeshProtos.FromRadio.PayloadVariantCase.DEVICEUICONFIG -> handleDeviceUiConfig(proto.deviceuiConfig) - - MeshProtos.FromRadio.PayloadVariantCase.FILEINFO -> handleFileInfo(proto.fileInfo) - - MeshProtos.FromRadio.PayloadVariantCase.CLIENTNOTIFICATION -> - handleClientNotification(proto.clientNotification) - - MeshProtos.FromRadio.PayloadVariantCase.LOG_RECORD -> {} - MeshProtos.FromRadio.PayloadVariantCase.REBOOTED -> {} - MeshProtos.FromRadio.PayloadVariantCase.XMODEMPACKET -> {} - MeshProtos.FromRadio.PayloadVariantCase.PAYLOADVARIANT_NOT_SET, - null, - -> errormsg("Unexpected FromRadio variant") - } + proto.route() } catch (ex: InvalidProtocolBufferException) { errormsg("Invalid Protobuf from radio, len=${bytes.size}", ex) } } + // A provisional MyNodeInfo that we will install if all of our node config downloads go okay + private var newMyNodeInfo: MyNodeEntity? = null + + // provisional NodeInfos we will install if all goes well + private val newNodes = mutableListOf() + private fun handleDeviceConfig(config: ConfigProtos.Config) { debug("Received config ${config.toOneLineString()}") - insertMeshLog( + val packetToSave = MeshLog( uuid = UUID.randomUUID().toString(), message_type = "Config ${config.payloadVariantCase}", received_date = System.currentTimeMillis(), raw_message = config.toString(), fromRadio = fromRadio { this.config = config }, - ), - ) + ) + insertMeshLog(packetToSave) setLocalConfig(config) val configCount = localConfig.allFields.size radioConfigRepository.setStatusMessage("Device config ($configCount / $configTotal)") @@ -1402,15 +1554,15 @@ class MeshService : private fun handleModuleConfig(config: ModuleConfigProtos.ModuleConfig) { debug("Received moduleConfig ${config.toOneLineString()}") - insertMeshLog( + val packetToSave = MeshLog( uuid = UUID.randomUUID().toString(), message_type = "ModuleConfig ${config.payloadVariantCase}", received_date = System.currentTimeMillis(), raw_message = config.toString(), - fromRadio = fromRadio { this.moduleConfig = config }, - ), - ) + fromRadio = fromRadio { moduleConfig = config }, + ) + insertMeshLog(packetToSave) setLocalModuleConfig(config) val moduleCount = moduleConfig.allFields.size radioConfigRepository.setStatusMessage("Module config ($moduleCount / $moduleTotal)") @@ -1419,36 +1571,33 @@ class MeshService : private fun handleQueueStatus(queueStatus: MeshProtos.QueueStatus) { debug("queueStatus ${queueStatus.toOneLineString()}") val (success, isFull, requestId) = with(queueStatus) { Triple(res == 0, free == 0, meshPacketId) } - if (success && isFull) return // Queue is full, wait for next update - - val future = - if (requestId != 0) { - queueResponse.remove(requestId) - } else { - // This is a bit of a guess, but for now we assume it's for the last request that isn't done. - // A more robust solution would involve matching something other than packetId. - queueResponse.entries.lastOrNull { !it.value.isDone }?.also { queueResponse.remove(it.key) }?.value - } - future?.complete(success) + if (success && isFull) return // Queue is full, wait for free != 0 + if (requestId != 0) { + queueResponse.remove(requestId)?.complete(success) + } else { + queueResponse.entries.lastOrNull { !it.value.isDone }?.value?.complete(success) + } } private fun handleChannel(ch: ChannelProtos.Channel) { debug("Received channel ${ch.index}") - insertMeshLog( + val packetToSave = MeshLog( uuid = UUID.randomUUID().toString(), message_type = "Channel", received_date = System.currentTimeMillis(), raw_message = ch.toString(), fromRadio = fromRadio { channel = ch }, - ), - ) + ) + insertMeshLog(packetToSave) if (ch.role != ChannelProtos.Channel.Role.DISABLED) updateChannelSettings(ch) val maxChannels = myNodeInfo?.maxChannels ?: 8 radioConfigRepository.setStatusMessage("Channels (${ch.index + 1} / $maxChannels)") } + /** Convert a protobuf NodeInfo into our model objects and update our node DB */ private fun installNodeInfo(info: MeshProtos.NodeInfo) { + // Just replace/add any entry updateNodeInfo(info.num) { if (info.hasUser()) { it.user = @@ -1459,18 +1608,30 @@ class MeshService : it.longName = it.user.longName it.shortName = it.user.shortName } + if (info.hasPosition()) { it.position = info.position it.latitude = Position.degD(info.position.latitudeI) it.longitude = Position.degD(info.position.longitudeI) } + it.lastHeard = info.lastHeard + if (info.hasDeviceMetrics()) { it.deviceTelemetry = telemetry { deviceMetrics = info.deviceMetrics } } + it.channel = info.channel it.viaMqtt = info.viaMqtt - it.hopsAway = if (info.hasHopsAway()) info.hopsAway else -1 + + // hopsAway should be nullable/optional from the proto, but explicitly checking it's + // existence first + it.hopsAway = + if (info.hasHopsAway()) { + info.hopsAway + } else { + -1 + } it.isFavorite = info.isFavorite it.isIgnored = info.isIgnored } @@ -1478,24 +1639,32 @@ class MeshService : private fun handleNodeInfo(info: MeshProtos.NodeInfo) { debug( - "Received nodeinfo num=${info.num}, hasUser=${info.hasUser()}, " + - "hasPosition=${info.hasPosition()}, hasDeviceMetrics=${info.hasDeviceMetrics()}", + "Received nodeinfo num=${info.num}," + + " hasUser=${info.hasUser()}," + + " hasPosition=${info.hasPosition()}," + + " hasDeviceMetrics=${info.hasDeviceMetrics()}", ) - insertMeshLog( + + val packetToSave = MeshLog( uuid = UUID.randomUUID().toString(), message_type = "NodeInfo", received_date = System.currentTimeMillis(), raw_message = info.toString(), fromRadio = fromRadio { nodeInfo = info }, - ), - ) + ) + insertMeshLog(packetToSave) - installNodeInfo(info) - onNodeDBChanged() - radioConfigRepository.setStatusMessage("Nodes ($numNodes)") + newNodes.add(info) + radioConfigRepository.setStatusMessage("Nodes (${newNodes.size})") } + private var rawMyNodeInfo: MeshProtos.MyNodeInfo? = null + + /** + * Regenerate the myNodeInfo model. We call this twice. Once after we receive myNodeInfo from the device and again + * after we have the node DB (which might allow us a better notion of our HwModel. + */ private fun regenMyNodeInfo(metadata: MeshProtos.DeviceMetadata) { val myInfo = rawMyNodeInfo if (myInfo != null) { @@ -1522,52 +1691,89 @@ class MeshService : deviceId = deviceId.toStringUtf8(), ) } - serviceScope.handledLaunch { - radioConfigRepository.installMyNodeInfo(mi) - radioConfigRepository.insertMetadata(mi.myNodeNum, metadata) - } - myNodeInfo = mi - onConnected() + serviceScope.handledLaunch { radioConfigRepository.insertMetadata(mi.myNodeNum, metadata) } + newMyNodeInfo = mi } } private fun sendAnalytics() { - myNodeInfo?.let { + val myInfo = rawMyNodeInfo + val mi = myNodeInfo + if (myInfo != null && mi != null) { + // Track types of devices and firmware versions in use GeeksvilleApplication.analytics.setUserInfo( - DataPair("firmware", it.firmwareVersion), - DataPair("hw_model", it.model), + DataPair("firmware", mi.firmwareVersion), + DataPair("hw_model", mi.model), ) } } + /** Update MyNodeInfo (called from either new API version or the old one) */ private fun handleMyInfo(myInfo: MeshProtos.MyNodeInfo) { - insertMeshLog( + val packetToSave = MeshLog( uuid = UUID.randomUUID().toString(), message_type = "MyNodeInfo", received_date = System.currentTimeMillis(), raw_message = myInfo.toString(), fromRadio = fromRadio { this.myInfo = myInfo }, - ), - ) + ) + insertMeshLog(packetToSave) + rawMyNodeInfo = myInfo + + // We'll need to get a new set of channels and settings now + serviceScope.handledLaunch { + radioConfigRepository.clearChannelSet() + radioConfigRepository.clearLocalConfig() + radioConfigRepository.clearLocalModuleConfig() + } } - private fun handleDeviceUiConfig(deviceuiConfig: DeviceUIProtos.DeviceUIConfig) { - debug("Received DeviceUIConfig ${deviceuiConfig.toOneLineString()}") + /** Update our DeviceMetadata */ + private fun handleMetadata(metadata: MeshProtos.DeviceMetadata) { + debug("Received deviceMetadata ${metadata.toOneLineString()}") val packetToSave = MeshLog( uuid = UUID.randomUUID().toString(), - message_type = "DeviceUIConfig", + message_type = "DeviceMetadata", received_date = System.currentTimeMillis(), - raw_message = deviceuiConfig.toString(), - fromRadio = fromRadio { this.deviceuiConfig = deviceuiConfig }, + raw_message = metadata.toString(), + fromRadio = fromRadio { this.metadata = metadata }, ) insertMeshLog(packetToSave) + + regenMyNodeInfo(metadata) + } + + /** Publish MqttClientProxyMessage (fromRadio) */ + private fun handleMqttProxyMessage(message: MeshProtos.MqttClientProxyMessage) { + with(message) { + when (payloadVariantCase) { + MeshProtos.MqttClientProxyMessage.PayloadVariantCase.TEXT -> { + mqttRepository.publish(topic, text.encodeToByteArray(), retained) + } + + MeshProtos.MqttClientProxyMessage.PayloadVariantCase.DATA -> { + mqttRepository.publish(topic, data.toByteArray(), retained) + } + + else -> {} + } + } + } + + private fun handleClientNotification(notification: MeshProtos.ClientNotification) { + debug("Received clientNotification ${notification.toOneLineString()}") + radioConfigRepository.setClientNotification(notification) + serviceNotifications.showClientNotification(notification) + // if the future for the originating request is still in the queue, complete as unsuccessful + // for now + queueResponse.remove(notification.replyId)?.complete(false) } private fun handleFileInfo(fileInfo: MeshProtos.FileInfo) { - debug("Received FileInfo ${fileInfo.toOneLineString()}") + debug("Received fileInfo ${fileInfo.toOneLineString()}") val packetToSave = MeshLog( uuid = UUID.randomUUID().toString(), @@ -1579,138 +1785,199 @@ class MeshService : insertMeshLog(packetToSave) } - private fun handleMetadata(metadata: MeshProtos.DeviceMetadata) { - debug("Received deviceMetadata ${metadata.toOneLineString()}") - insertMeshLog( + private fun handleLogReord(logRecord: MeshProtos.LogRecord) { + debug("Received logRecord ${logRecord.toOneLineString()}") + val packetToSave = MeshLog( uuid = UUID.randomUUID().toString(), - message_type = "DeviceMetadata", + message_type = "LogRecord", received_date = System.currentTimeMillis(), - raw_message = metadata.toString(), - fromRadio = fromRadio { this.metadata = metadata }, - ), - ) - regenMyNodeInfo(metadata) + raw_message = logRecord.toString(), + fromRadio = fromRadio { this.logRecord = logRecord }, + ) + insertMeshLog(packetToSave) } - private fun handleMqttProxyMessage(message: MeshProtos.MqttClientProxyMessage) { - with(message) { - when (payloadVariantCase) { - MeshProtos.MqttClientProxyMessage.PayloadVariantCase.TEXT -> - mqttRepository.publish(topic, text.encodeToByteArray(), retained) - - MeshProtos.MqttClientProxyMessage.PayloadVariantCase.DATA -> - mqttRepository.publish(topic, data.toByteArray(), retained) - - else -> Unit - } - } + private fun handleRebooted(rebooted: Boolean) { + debug("Received rebooted ${rebooted.toOneLineString()}") + val packetToSave = + MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "Rebooted", + received_date = System.currentTimeMillis(), + raw_message = rebooted.toString(), + fromRadio = fromRadio { this.rebooted = rebooted }, + ) + insertMeshLog(packetToSave) } - private fun handleClientNotification(notification: MeshProtos.ClientNotification) { - debug("Received clientNotification ${notification.toOneLineString()}") - radioConfigRepository.setClientNotification(notification) - serviceNotifications.showClientNotification(notification) - queueResponse.remove(notification.replyId)?.complete(false) + private fun handleXmodemPacket(xmodemPacket: XmodemProtos.XModem) { + debug("Received XmodemPacket ${xmodemPacket.toOneLineString()}") + val packetToSave = + MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "XmodemPacket", + received_date = System.currentTimeMillis(), + raw_message = xmodemPacket.toString(), + fromRadio = fromRadio { this.xmodemPacket = xmodemPacket }, + ) + insertMeshLog(packetToSave) } + private fun handleDeviceUiConfig(deviceuiConfig: DeviceUIProtos.DeviceUIConfig) { + debug("Received deviceUIConfig ${deviceuiConfig.toOneLineString()}") + val packetToSave = + MeshLog( + uuid = UUID.randomUUID().toString(), + message_type = "DeviceUIConfig", + received_date = System.currentTimeMillis(), + raw_message = deviceuiConfig.toString(), + fromRadio = fromRadio { this.deviceuiConfig = deviceuiConfig }, + ) + insertMeshLog(packetToSave) + } + + /** Connect, subscribe and receive Flow of MqttClientProxyMessage (toRadio) */ private fun startMqttClientProxy() { if (mqttMessageFlow?.isActive == true) return if (moduleConfig.mqtt.enabled && moduleConfig.mqtt.proxyToClientEnabled) { mqttMessageFlow = mqttRepository.proxyMessageFlow - .onEach { message -> sendToRadio(ToRadio.newBuilder().setMqttClientProxyMessage(message)) } + .onEach { message -> sendToRadio(ToRadio.newBuilder().apply { mqttClientProxyMessage = message }) } .catch { throwable -> radioConfigRepository.setErrorMessage("MqttClientProxy failed: $throwable") } .launchIn(serviceScope) } } private fun stopMqttClientProxy() { - mqttMessageFlow - ?.takeIf { it.isActive } - ?.let { - info("Stopping MqttClientProxy") - it.cancel() - mqttMessageFlow = null - } - } - - private fun onConnected() { - // Start sending queued packets and other tasks - processQueuedPackets() - startMqttClientProxy() - onNodeDBChanged() - serviceBroadcasts.broadcastConnection() - sendAnalytics() - reportConnection() - sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { setTimeOnly = currentSecond() }) - } - - private fun handleConfigComplete(configCompleteId: Int) { - when (configCompleteId) { - CONFIG_ONLY_NONCE -> handleConfigOnlyNonceResponse() - NODE_INFO_ONLY_NONCE -> handleNodeInfoNonceResponse() - else -> warn("Received unexpected config complete id $configCompleteId") + if (mqttMessageFlow?.isActive == true) { + info("Stopping MqttClientProxy") + mqttMessageFlow?.cancel() + mqttMessageFlow = null } } - private fun handleConfigOnlyNonceResponse() { + // If we've received our initial config, our radio settings and all of our channels, send any + // queued packets and + // broadcast connected to clients + private fun onHasSettings() { + processQueuedPackets() // send any packets that were queued up + startMqttClientProxy() + serviceBroadcasts.broadcastConnection() + sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { setTimeOnly = currentSecond() }) + sendAnalytics() + reportConnection() + } + + private fun handleConfigComplete(configCompleteId: Int) { + if (configCompleteId == CONFIG_ONLY_NONCE) { + debug("Received config complete for config-only nonce $CONFIG_ONLY_NONCE") + handleConfigOnlyComplete() + } else if (configCompleteId == NODE_INFO_ONLY_NONCE) { + debug("Received node info complete for nonce $NODE_INFO_ONLY_NONCE") + handleNodeInfoComplete() + } else { + warn("Received unexpected config complete id $configCompleteId") + } + } + + private fun handleConfigOnlyComplete() { debug("Received config only complete for nonce $CONFIG_ONLY_NONCE") - insertMeshLog( + val packetToSave = MeshLog( uuid = UUID.randomUUID().toString(), message_type = "ConfigOnlyComplete", received_date = System.currentTimeMillis(), raw_message = CONFIG_ONLY_NONCE.toString(), fromRadio = fromRadio { this.configCompleteId = CONFIG_ONLY_NONCE }, - ), - ) - // we have recieved the response to our ConfigOnly request - // send a heartbeat, then request NodeInfoOnly to get the nodeDb from the radio + ) + insertMeshLog(packetToSave) + + // This was our config request + if (newMyNodeInfo == null) { + errormsg("Did not receive a valid config") + } else { + myNodeInfo = newMyNodeInfo + } serviceScope.handledLaunch { delay(CONFIG_WAIT_MS) radioInterfaceService.keepAlive() delay(CONFIG_WAIT_MS) - sendNodeInfoOnlyRequest() } + startNodeInfoOnly() + onHasSettings() } - private fun handleNodeInfoNonceResponse() { + private fun handleNodeInfoComplete() { debug("Received node info complete for nonce $NODE_INFO_ONLY_NONCE") - insertMeshLog( + val packetToSave = MeshLog( uuid = UUID.randomUUID().toString(), message_type = "NodeInfoComplete", received_date = System.currentTimeMillis(), raw_message = NODE_INFO_ONLY_NONCE.toString(), fromRadio = fromRadio { this.configCompleteId = NODE_INFO_ONLY_NONCE }, - ), - ) + ) + insertMeshLog(packetToSave) + + // This was our config request + if (newNodes.isEmpty()) { + errormsg("Did not receive a valid node info") + } else { + newNodes.forEach(::installNodeInfo) + newNodes.clear() // Just to save RAM ;-) + + serviceScope.handledLaunch { + radioConfigRepository.installMyNodeInfo(myNodeInfo!!) + radioConfigRepository.installNodeDb(nodeDBbyNodeNum.values.toList()) + } + + haveNodeDB = true // we now have nodes from real hardware + + sendAnalytics() + onNodeDBChanged() + } } - private fun sendConfigOnlyRequest() { - debug("Starting config only with nonce=$CONFIG_ONLY_NONCE") - sendToRadio(ToRadio.newBuilder().setWantConfigId(CONFIG_ONLY_NONCE)) + /** Start the modern (REV2) API configuration flow */ + private fun startConfigOnly() { + newMyNodeInfo = null + + debug("Starting config only nonce=$CONFIG_ONLY_NONCE") + + sendToRadio(ToRadio.newBuilder().apply { this.wantConfigId = CONFIG_ONLY_NONCE }) } - private fun sendNodeInfoOnlyRequest() { - debug("Starting node info with nonce=$NODE_INFO_ONLY_NONCE") - sendToRadio(ToRadio.newBuilder().setWantConfigId(NODE_INFO_ONLY_NONCE)) + private fun startNodeInfoOnly() { + newNodes.clear() + + debug("Starting node info nonce=$NODE_INFO_ONLY_NONCE") + + sendToRadio(ToRadio.newBuilder().apply { this.wantConfigId = NODE_INFO_ONLY_NONCE }) } + /** Send a position (typically from our built in GPS) into the mesh. */ private fun sendPosition(position: MeshProtos.Position, destNum: Int? = null, wantResponse: Boolean = false) { try { - myNodeInfo?.let { mi -> - val targetNodeNum = destNum ?: mi.myNodeNum - debug("Sending our position/time to=$targetNodeNum ${Position(position)}") + val mi = myNodeInfo + if (mi != null) { + val idNum = destNum ?: mi.myNodeNum // when null we just send to the local node + debug("Sending our position/time to=$idNum ${Position(position)}") + // Also update our own map for our nodeNum, by handling the packet just like packets + // from other users if (!localConfig.position.fixedPosition) { handleReceivedPosition(mi.myNodeNum, position) } sendToRadio( - newMeshPacketTo(targetNodeNum).buildMeshPacket( - channel = if (destNum == null) 0 else (nodeDBbyNodeNum[destNum]?.channel ?: 0), + newMeshPacketTo(idNum).buildMeshPacket( + channel = + if (destNum == null) { + 0 + } else { + nodeDBbyNodeNum[destNum]?.channel ?: 0 + }, priority = MeshPacket.Priority.BACKGROUND, ) { portnumValue = Portnums.PortNum.POSITION_APP_VALUE @@ -1720,32 +1987,52 @@ class MeshService : ) } } catch (ex: BLEException) { - warn("Ignoring disconnected radio during gps location update: ${ex.message}") + warn("Ignoring disconnected radio during gps location update") } } - private fun setOwner(packetId: Int, user: MeshProtos.User) { - val dest = nodeDBbyID[user.id] ?: throw Exception("Can't set user without a NodeInfo") - if (user == dest.user) { + /** Send setOwner admin packet with [MeshProtos.User] protobuf */ + private fun setOwner(packetId: Int, user: MeshProtos.User) = with(user) { + val dest = nodeDBbyID[id] ?: throw Exception("Can't set user without a NodeInfo") // this shouldn't happen + val old = dest.user + + @Suppress("ComplexCondition") + if (user == old) { debug("Ignoring nop owner change") - return - } + } else { + debug( + "setOwner Id: $id longName: ${longName.anonymize}" + + " shortName: $shortName isLicensed: $isLicensed" + + " isUnmessagable: $isUnmessagable", + ) - debug("setOwner Id: ${user.id} longName: ${user.longName.anonymize} shortName: ${user.shortName}") - handleReceivedUser(dest.num, user) - sendToRadio(newMeshPacketTo(dest.num).buildAdminPacket(id = packetId) { setOwner = user }) + // Also update our own map for our nodeNum, by handling the packet just like packets + // from other users + handleReceivedUser(dest.num, user) + + // encapsulate our payload in the proper protobuf and fire it off + sendToRadio(newMeshPacketTo(dest.num).buildAdminPacket(id = packetId) { setOwner = user }) + } } - private val packetIdGenerator = AtomicLong(Random().nextLong()) + // Do not use directly, instead call generatePacketId() + private var currentPacketId = Random(System.currentTimeMillis()).nextLong().absoluteValue + /** Generate a unique packet ID (if we know enough to do so - otherwise return 0 so the device will do it) */ + @Synchronized private fun generatePacketId(): Int { - // We need a 32 bit unsigned integer, but since Java doesn't have unsigned, - // we can use a long and mask it. To ensure it's never 0, we add 1 after masking. - return (packetIdGenerator.incrementAndGet() and 0xFFFFFFFFL).toInt().let { if (it == 0) 1 else it } + val numPacketIds = ((1L shl 32) - 1) // A mask for only the valid packet ID bits, either 255 or maxint + + currentPacketId++ + + currentPacketId = currentPacketId and 0xffffffff // keep from exceeding 32 bits + + // Use modulus and +1 to ensure we skip 0 on any values we return + return ((currentPacketId % numPacketIds) + 1L).toInt() } private fun enqueueForSending(p: DataPacket) { - if (p.dataType in rememberableDataTypes) { + if (p.dataType in rememberDataType) { offlineSentPackets.add(p) } } @@ -1802,18 +2089,19 @@ class MeshService : } private fun sendReaction(reaction: ServiceAction.Reaction) = toRemoteExceptions { + // contactKey: unique contact key filter (channel)+(nodeId) val channel = reaction.contactKey[0].digitToInt() - val destId = reaction.contactKey.substring(1) + val destNum = reaction.contactKey.substring(1) val packet = - newMeshPacketTo(destId).buildMeshPacket(channel = channel, priority = MeshPacket.Priority.BACKGROUND) { + newMeshPacketTo(destNum).buildMeshPacket(channel = channel, priority = MeshPacket.Priority.BACKGROUND) { emoji = 1 replyId = reaction.replyId portnumValue = Portnums.PortNum.TEXT_MESSAGE_APP_VALUE payload = ByteString.copyFrom(reaction.emoji.encodeToByteArray()) } sendToRadio(packet) - rememberReaction(packet.toBuilder().setFrom(myNodeNum).build()) + rememberReaction(packet.copy { from = myNodeNum }) } private val _lastAddress: MutableStateFlow = MutableStateFlow(null) @@ -1822,15 +2110,35 @@ class MeshService : lateinit var sharedPreferences: SharedPreferences - private fun updateLastAddress(deviceAddr: String?) { - val currentAddr = lastAddress.value - debug("setDeviceAddress: New: ${deviceAddr.anonymize}, Old: ${currentAddr.anonymize}") + fun clearDatabases() = serviceScope.handledLaunch { + debug("Clearing nodeDB") + radioConfigRepository.clearNodeDB() + } - if (deviceAddr != currentAddr) { - _lastAddress.value = deviceAddr ?: NO_DEVICE_SELECTED - sharedPreferences.edit { putString(DEVICE_ADDRESS_KEY, deviceAddr) } - serviceScope.handledLaunch { resetState() } - clearNotifications() + private fun updateLastAddress(deviceAddr: String?) { + debug("setDeviceAddress: Passing through device change to radio service: ${deviceAddr.anonymize}") + when (deviceAddr) { + null, + "", + -> { + debug("SetDeviceAddress: No previous device address, setting new one") + _lastAddress.value = deviceAddr + sharedPreferences.edit { putString("device_address", deviceAddr) } + } + + lastAddress.value, + NO_DEVICE_SELECTED, + -> { + debug("SetDeviceAddress: Device address is the none or same, ignoring") + } + + else -> { + debug("SetDeviceAddress: Device address changed from $lastAddress to $deviceAddr") + _lastAddress.value = deviceAddr + sharedPreferences.edit { putString("device_address", deviceAddr) } + clearDatabases() + clearNotifications() + } } } @@ -1840,26 +2148,37 @@ class MeshService : private val binder = object : IMeshService.Stub() { + override fun setDeviceAddress(deviceAddr: String?) = toRemoteExceptions { debug("Passing through device change to radio service: ${deviceAddr.anonymize}") updateLastAddress(deviceAddr) - sharedPreferences.edit { putString("device_address", deviceAddr) } - connectionRouter.setDeviceAddress(deviceAddr) + val res = radioInterfaceService.setDeviceAddress(deviceAddr) + if (res) { + discardNodeDB() + } else { + serviceBroadcasts.broadcastConnection() + } + res } + // Note: bound methods don't get properly exception caught/logged, so do that with a + // wrapper + // per https://blog.classycode.com/dealing-with-exceptions-in-aidl-9ba904c6d63 override fun subscribeReceiver(packageName: String, receiverName: String) = toRemoteExceptions { clientPackages[receiverName] = packageName } - override fun getUpdateStatus(): Int = -4 // ProgressNotStarted (DEPRECATED) + override fun getUpdateStatus(): Int = -4 // ProgressNotStarted - override fun startFirmwareUpdate() = toRemoteExceptions {} + override fun startFirmwareUpdate() = toRemoteExceptions { + // TODO reimplement this after we have a new firmware update mechanism + } override fun getMyNodeInfo(): MyNodeInfo? = this@MeshService.myNodeInfo?.toMyNodeInfo() - override fun getMyId(): String = toRemoteExceptions { myNodeID } + override fun getMyId() = toRemoteExceptions { myNodeID } - override fun getPacketId(): Int = toRemoteExceptions { generatePacketId() } + override fun getPacketId() = toRemoteExceptions { generatePacketId() } override fun setOwner(user: MeshUser) = toRemoteExceptions { setOwner( @@ -1887,20 +2206,24 @@ class MeshService : override fun send(p: DataPacket) { toRemoteExceptions { if (p.id == 0) p.id = generatePacketId() + info( - "sendData dest=${p.to}, id=${p.id} <- ${p.bytes?.size} bytes " + - "(connectionState=${connectionRouter.connectionState.value})", + "sendData dest=${p.to}, id=${p.id} <- ${p.bytes!!.size} bytes" + + " (connectionState=$connectionState)", ) - if (p.dataType == 0) throw InvalidProtocolBufferException("Port numbers must be non-zero") - if ((p.bytes?.size ?: 0) >= MeshProtos.Constants.DATA_PAYLOAD_LEN_VALUE) { + if (p.dataType == 0) { + throw Exception("Port numbers must be non-zero!") // we are now more strict + } + + if (p.bytes.size >= MeshProtos.Constants.DATA_PAYLOAD_LEN.number) { p.status = MessageStatus.ERROR throw RemoteException("Message too long") } else { p.status = MessageStatus.QUEUED } - if (connectionRouter.connectionState.value == ConnectionState.CONNECTED) { + if (connectionState == ConnectionState.CONNECTED) { try { sendNow(p) } catch (ex: Exception) { @@ -1911,13 +2234,16 @@ class MeshService : enqueueForSending(p) } serviceBroadcasts.broadcastMessageStatus(p) + + // Keep a record of DataPackets, so GUIs can show proper chat history rememberDataPacket(p, false) GeeksvilleApplication.analytics.track( "data_send", - DataPair("num_bytes", p.bytes?.size), + DataPair("num_bytes", p.bytes.size), DataPair("type", p.dataType), ) + GeeksvilleApplication.analytics.track("num_data_sent", DataPair(1)) } } @@ -1926,6 +2252,7 @@ class MeshService : this@MeshService.localConfig.toByteArray() ?: throw NoDeviceConfigException() } + /** Send our current radio config to the device */ override fun setConfig(payload: ByteArray) = toRemoteExceptions { setRemoteConfig(generatePacketId(), myNodeNum, payload) } @@ -1934,22 +2261,27 @@ class MeshService : debug("Setting new radio config!") val config = ConfigProtos.Config.parseFrom(payload) sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setConfig = config }) - if (num == myNodeNum) setLocalConfig(config) + if (num == myNodeNum) setLocalConfig(config) // Update our local copy } override fun getRemoteConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions { sendToRadio( newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { - getConfigRequestValue = config + if (config == AdminProtos.AdminMessage.ConfigType.SESSIONKEY_CONFIG_VALUE) { + getDeviceMetadataRequest = true + } else { + getConfigRequestValue = config + } }, ) } + /** Send our current module config to the device */ override fun setModuleConfig(id: Int, num: Int, payload: ByteArray) = toRemoteExceptions { debug("Setting new module config!") val config = ModuleConfigProtos.ModuleConfig.parseFrom(payload) sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setModuleConfig = config }) - if (num == myNodeNum) setLocalModuleConfig(config) + if (num == myNodeNum) setLocalModuleConfig(config) // Update our local copy } override fun getModuleConfig(id: Int, destNum: Int, config: Int) = toRemoteExceptions { @@ -1984,11 +2316,11 @@ class MeshService : ) } - override fun setChannel(payload: ByteArray) = toRemoteExceptions { + override fun setChannel(payload: ByteArray?) = toRemoteExceptions { setRemoteChannel(generatePacketId(), myNodeNum, payload) } - override fun setRemoteChannel(id: Int, num: Int, payload: ByteArray) = toRemoteExceptions { + override fun setRemoteChannel(id: Int, num: Int, payload: ByteArray?) = toRemoteExceptions { val channel = ChannelProtos.Channel.parseFrom(payload) sendToRadio(newMeshPacketTo(num).buildAdminPacket(id = id) { setChannel = channel }) } @@ -1996,7 +2328,7 @@ class MeshService : override fun getRemoteChannel(id: Int, destNum: Int, index: Int) = toRemoteExceptions { sendToRadio( newMeshPacketTo(destNum).buildAdminPacket(id = id, wantResponse = true) { - getChannelRequest = index + 1 // API is 1-based + getChannelRequest = index + 1 }, ) } @@ -2012,27 +2344,25 @@ class MeshService : override fun getChannelSet(): ByteArray = toRemoteExceptions { this@MeshService.channelSet.toByteArray() } override fun getNodes(): MutableList = toRemoteExceptions { - nodeDBbyNodeNum.values.map { it.toNodeInfo() }.toMutableList() + val r = nodeDBbyNodeNum.values.map { it.toNodeInfo() }.toMutableList() + info("in getOnline, count=${r.size}") + // return arrayOf("+16508675309") + r } override fun connectionState(): String = toRemoteExceptions { - this@MeshService.connectionRouter.connectionState.value.toString() + val r = this@MeshService.connectionState + info("in connectionState=$r") + r.toString() } - override fun startProvideLocation() = toRemoteExceptions { - @SuppressLint("MissingPermission") - startLocationRequests() - } + override fun startProvideLocation() = toRemoteExceptions { startLocationRequests() } override fun stopProvideLocation() = toRemoteExceptions { stopLocationRequests() } override fun removeByNodenum(requestId: Int, nodeNum: Int) = toRemoteExceptions { - nodeDBbyNodeNum.remove(nodeNum)?.let { removedNode -> - if (removedNode.user.id.isNotEmpty()) { - _nodeDBbyID.remove(removedNode.user.id) - } - } - sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { this.removeByNodenum = nodeNum }) + nodeDBbyNodeNum.remove(nodeNum) + sendToRadio(newMeshPacketTo(myNodeNum).buildAdminPacket { removeByNodenum = nodeNum }) } override fun requestUserInfo(destNum: Int) = toRemoteExceptions { @@ -2041,44 +2371,50 @@ class MeshService : newMeshPacketTo(destNum).buildMeshPacket(channel = nodeDBbyNodeNum[destNum]?.channel ?: 0) { portnumValue = Portnums.PortNum.NODEINFO_APP_VALUE wantResponse = true - payload = nodeDBbyNodeNum[myNodeNum]?.user?.toByteString() ?: ByteString.EMPTY + payload = nodeDBbyNodeNum[myNodeNum]!!.user.toByteString() }, ) } } override fun requestPosition(destNum: Int, position: Position) = toRemoteExceptions { - if (destNum == myNodeNum) return@toRemoteExceptions + if (destNum != myNodeNum) { + // Determine the best position to send based on user preferences and available + // data + val provideLocation = sharedPreferences.getBoolean("provide-location-$myNodeNum", false) + val currentPosition = + when { + // Use provided position if valid and user allows phone location sharing + provideLocation && position.isValid() -> position + // Otherwise use the last valid position from nodeDB (node GPS or + // static) + else -> nodeDBbyNodeNum[myNodeNum]?.position?.let { Position(it) }?.takeIf { it.isValid() } + } - val provideLocation = sharedPreferences.getBoolean("provide-location-$myNodeNum", false) - val currentPosition = - when { - provideLocation && position.isValid() -> position - else -> nodeDBbyNodeNum[myNodeNum]?.position?.let { Position(it) }?.takeIf { it.isValid() } + if (currentPosition == null) { + debug("Position request skipped - no valid position available") + return@toRemoteExceptions } - if (currentPosition == null) { - debug("Position request skipped - no valid position available") - return@toRemoteExceptions - } + // Convert Position to MeshProtos.Position for the payload + val meshPosition = position { + latitudeI = Position.degI(currentPosition.latitude) + longitudeI = Position.degI(currentPosition.longitude) + altitude = currentPosition.altitude + time = currentSecond() + } - val meshPosition = position { - latitudeI = Position.degI(currentPosition.latitude) - longitudeI = Position.degI(currentPosition.longitude) - altitude = currentPosition.altitude - time = currentSecond() + sendToRadio( + newMeshPacketTo(destNum).buildMeshPacket( + channel = nodeDBbyNodeNum[destNum]?.channel ?: 0, + priority = MeshPacket.Priority.BACKGROUND, + ) { + portnumValue = Portnums.PortNum.POSITION_APP_VALUE + payload = meshPosition.toByteString() + wantResponse = true + }, + ) } - - sendToRadio( - newMeshPacketTo(destNum).buildMeshPacket( - channel = nodeDBbyNodeNum[destNum]?.channel ?: 0, - priority = MeshPacket.Priority.BACKGROUND, - ) { - portnumValue = Portnums.PortNum.POSITION_APP_VALUE - payload = meshPosition.toByteString() - wantResponse = true - }, - ) } override fun setFixedPosition(destNum: Int, position: Position) = toRemoteExceptions { @@ -2089,7 +2425,7 @@ class MeshService : } sendToRadio( newMeshPacketTo(destNum).buildAdminPacket { - if (position.latitude != 0.0 || position.longitude != 0.0 || position.altitude != 0) { + if (position != Position(0.0, 0.0, 0)) { setFixedPosition = pos } else { removeFixedPosition = true diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt index b47f0caa2..4d622cc71 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt @@ -28,11 +28,11 @@ import android.content.Intent import android.graphics.Color import android.media.AudioAttributes import android.media.RingtoneManager -import android.os.Build -import androidx.annotation.RequiresApi +import androidx.annotation.StringRes import androidx.core.app.NotificationCompat import androidx.core.app.Person import androidx.core.app.RemoteInput +import androidx.core.content.getSystemService import androidx.core.net.toUri import com.geeksville.mesh.MainActivity import com.geeksville.mesh.MeshProtos @@ -43,452 +43,240 @@ import com.geeksville.mesh.navigation.DEEP_LINK_BASE_URI import com.geeksville.mesh.service.ReplyReceiver.Companion.KEY_TEXT_REPLY import com.geeksville.mesh.util.formatUptime +/** + * Manages the creation and display of all app notifications. + * + * This class centralizes notification logic, including channel creation, builder configuration, and displaying + * notifications for various events like new messages, alerts, and service status changes. + */ @Suppress("TooManyFunctions") class MeshServiceNotifications(private val context: Context) { - val notificationLightColor = Color.BLUE + private val notificationManager = context.getSystemService()!! companion object { private const val FIFTEEN_MINUTES_IN_MILLIS = 15L * 60 * 1000 const val MAX_BATTERY_LEVEL = 100 + const val SERVICE_NOTIFY_ID = 101 + private val NOTIFICATION_LIGHT_COLOR = Color.BLUE } - private val notificationManager: NotificationManager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + /** + * Sealed class to define the properties of each notification channel. This centralizes channel configuration and + * makes it type-safe. + */ + private sealed class NotificationType( + val channelId: String, + @StringRes val channelNameRes: Int, + val importance: Int, + ) { + object ServiceState : + NotificationType( + "my_service", + R.string.meshtastic_service_notifications, + NotificationManager.IMPORTANCE_MIN, + ) - // We have two notification channels: one for general service status and another one for messages - val notifyId = 101 + object DirectMessage : + NotificationType( + "my_messages", + R.string.meshtastic_messages_notifications, + NotificationManager.IMPORTANCE_HIGH, + ) + + object BroadcastMessage : + NotificationType( + "my_broadcasts", + R.string.meshtastic_broadcast_notifications, + NotificationManager.IMPORTANCE_DEFAULT, + ) + + object Alert : + NotificationType( + "my_alerts", + R.string.meshtastic_alerts_notifications, + NotificationManager.IMPORTANCE_HIGH, + ) + + object NewNode : + NotificationType( + "new_nodes", + R.string.meshtastic_new_nodes_notifications, + NotificationManager.IMPORTANCE_DEFAULT, + ) + + object LowBatteryLocal : + NotificationType( + "low_battery", + R.string.meshtastic_low_battery_notifications, + NotificationManager.IMPORTANCE_DEFAULT, + ) + + object LowBatteryRemote : + NotificationType( + "low_battery_remote", + R.string.meshtastic_low_battery_temporary_remote_notifications, + NotificationManager.IMPORTANCE_DEFAULT, + ) + + object Client : + NotificationType("client_notifications", R.string.client_notification, NotificationManager.IMPORTANCE_HIGH) + + companion object { + // A list of all types for easy initialization. + fun allTypes() = listOf( + ServiceState, + DirectMessage, + BroadcastMessage, + Alert, + NewNode, + LowBatteryLocal, + LowBatteryRemote, + Client, + ) + } + } fun clearNotifications() { notificationManager.cancelAll() } + /** + * Creates all necessary notification channels on devices running Android O or newer. This should be called once + * when the service is created. + */ fun initChannels() { - // create notification channels on service creation - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createNotificationChannel() - createMessageNotificationChannel() - createBroadcastNotificationChannel() - createAlertNotificationChannel() - createNewNodeNotificationChannel() - createLowBatteryNotificationChannel() - createLowBatteryRemoteNotificationChannel() - createClientNotificationChannel() - } + NotificationType.allTypes().forEach { type -> createNotificationChannel(type) } } - @RequiresApi(Build.VERSION_CODES.O) - private fun createNotificationChannel(): String { - val channelId = "my_service" - if (notificationManager.getNotificationChannel(channelId) == null) { - val channelName = context.getString(R.string.meshtastic_service_notifications) - val channel = - NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_MIN).apply { - lightColor = notificationLightColor - lockscreenVisibility = Notification.VISIBILITY_PRIVATE + private fun createNotificationChannel(type: NotificationType) { + if (notificationManager.getNotificationChannel(type.channelId) != null) return + + val channelName = context.getString(type.channelNameRes) + val channel = + NotificationChannel(type.channelId, channelName, type.importance).apply { + lightColor = NOTIFICATION_LIGHT_COLOR + lockscreenVisibility = Notification.VISIBILITY_PUBLIC // Default, can be overridden + + // Type-specific configurations + when (type) { + NotificationType.ServiceState -> { + lockscreenVisibility = Notification.VISIBILITY_PRIVATE + } + NotificationType.DirectMessage, + NotificationType.BroadcastMessage, + NotificationType.NewNode, + NotificationType.LowBatteryLocal, + NotificationType.LowBatteryRemote, + -> { + setShowBadge(true) + setSound( + RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build(), + ) + if (type == NotificationType.LowBatteryRemote) enableVibration(true) + } + NotificationType.Alert -> { + setShowBadge(true) + enableLights(true) + enableVibration(true) + setBypassDnd(true) + val alertSoundUri = + "${ContentResolver.SCHEME_ANDROID_RESOURCE}://${context.packageName}/${R.raw.alert}".toUri() + setSound( + alertSoundUri, + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ALARM) // More appropriate for an alert + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build(), + ) + } + NotificationType.Client -> { + setShowBadge(true) + } } - notificationManager.createNotificationChannel(channel) - } - return channelId - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun createMessageNotificationChannel(): String { - val channelId = "my_messages" - if (notificationManager.getNotificationChannel(channelId) == null) { - val channelName = context.getString(R.string.meshtastic_messages_notifications) - val channel = - NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH).apply { - lightColor = notificationLightColor - lockscreenVisibility = Notification.VISIBILITY_PUBLIC - setShowBadge(true) - setSound( - RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), - AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_NOTIFICATION) - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .build(), - ) - } - notificationManager.createNotificationChannel(channel) - } - return channelId - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun createBroadcastNotificationChannel(): String { - val channelId = "my_broadcasts" - if (notificationManager.getNotificationChannel(channelId) == null) { - val channelName = context.getString(R.string.meshtastic_broadcast_notifications) - val channel = - NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_DEFAULT).apply { - lightColor = notificationLightColor - lockscreenVisibility = Notification.VISIBILITY_PUBLIC - setShowBadge(true) - setSound( - RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), - AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_NOTIFICATION) - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .build(), - ) - } - notificationManager.createNotificationChannel(channel) - } - return channelId - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun createAlertNotificationChannel(): String { - val channelId = "my_alerts" - if (notificationManager.getNotificationChannel(channelId) == null) { - val channelName = context.getString(R.string.meshtastic_alerts_notifications) - val channel = - NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH).apply { - enableLights(true) - enableVibration(true) - setBypassDnd(true) - lightColor = notificationLightColor - lockscreenVisibility = Notification.VISIBILITY_PUBLIC - setShowBadge(true) - val alertSoundUri = - ( - ContentResolver.SCHEME_ANDROID_RESOURCE + - "://" + - context.applicationContext.packageName + - "/" + - R.raw.alert - ) - .toUri() - setSound( - alertSoundUri, - AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_NOTIFICATION) - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .build(), - ) - } - notificationManager.createNotificationChannel(channel) - } - return channelId - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun createNewNodeNotificationChannel(): String { - val channelId = "new_nodes" - if (notificationManager.getNotificationChannel(channelId) == null) { - val channelName = context.getString(R.string.meshtastic_new_nodes_notifications) - val channel = - NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH).apply { - lightColor = notificationLightColor - lockscreenVisibility = Notification.VISIBILITY_PUBLIC - setShowBadge(true) - setSound( - RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), - AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_NOTIFICATION) - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .build(), - ) - } - notificationManager.createNotificationChannel(channel) - } - return channelId - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun createLowBatteryNotificationChannel(): String { - val channelId = "low_battery" - if (notificationManager.getNotificationChannel(channelId) == null) { - val channelName = context.getString(R.string.meshtastic_low_battery_notifications) - val channel = - NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH).apply { - lightColor = notificationLightColor - lockscreenVisibility = Notification.VISIBILITY_PUBLIC - setShowBadge(true) - setSound( - RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), - AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_NOTIFICATION) - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .build(), - ) - } - notificationManager.createNotificationChannel(channel) - } - return channelId - } - - // FIXME, Once we get a dedicated settings page in the app, this function should be removed and - // the feature should be implemented in the regular low battery notification stuff - @RequiresApi(Build.VERSION_CODES.O) - private fun createLowBatteryRemoteNotificationChannel(): String { - val channelId = "low_battery_remote" - if (notificationManager.getNotificationChannel(channelId) == null) { - val channelName = context.getString(R.string.meshtastic_low_battery_temporary_remote_notifications) - val channel = - NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH).apply { - lightColor = notificationLightColor - lockscreenVisibility = Notification.VISIBILITY_PUBLIC - enableVibration(true) - setShowBadge(true) - setSound( - RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION), - AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_NOTIFICATION) - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .build(), - ) - } - notificationManager.createNotificationChannel(channel) - } - return channelId - } - - @RequiresApi(Build.VERSION_CODES.O) - private fun createClientNotificationChannel(): String { - val channelId = "client_notifications" - if (notificationManager.getNotificationChannel(channelId) == null) { - val channelName = context.getString(R.string.client_notification) - val channel = - NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH).apply { - lightColor = notificationLightColor - lockscreenVisibility = Notification.VISIBILITY_PUBLIC - setShowBadge(true) - } - notificationManager.createNotificationChannel(channel) - } - return channelId - } - - private val channelId: String by lazy { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createNotificationChannel() - } else { - // If earlier version channel ID is not used - // https://developer.android.com/reference/android/support/v4/app/NotificationCompat.Builder.html#NotificationCompat.Builder(android.content.Context) - "" - } - } - - private val messageChannelId: String by lazy { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createMessageNotificationChannel() - } else { - // If earlier version channel ID is not used - // https://developer.android.com/reference/android/support/v4/app/NotificationCompat.Builder.html#NotificationCompat.Builder(android.content.Context) - "" - } - } - - private val broadcastChannelId: String by lazy { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createBroadcastNotificationChannel() - } else { - "" - } - } - - private val alertChannelId: String by lazy { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createAlertNotificationChannel() - } else { - "" - } - } - - private val newNodeChannelId: String by lazy { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createNewNodeNotificationChannel() - } else { - "" - } - } - - private val lowBatteryChannelId: String by lazy { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createLowBatteryNotificationChannel() - } else { - "" - } - } - - // FIXME, Once we get a dedicated settings page in the app, this function should be removed and - // the feature should be implemented in the regular low battery notification stuff - private val lowBatteryRemoteChannelId: String by lazy { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createLowBatteryRemoteNotificationChannel() - } else { - "" - } - } - - private val clientNotificationChannelId: String by lazy { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - createClientNotificationChannel() - } else { - "" - } - } - - private fun LocalStats?.formatToString(): String = this?.allFields - ?.mapNotNull { (k, v) -> - when (k.name) { - "num_online_nodes", - "num_total_nodes", - -> return@mapNotNull null - "uptime_seconds" -> "Uptime: ${formatUptime(v as Int)}" - "channel_utilization" -> "ChUtil: %.2f%%".format(v) - "air_util_tx" -> "AirUtilTX: %.2f%%".format(v) - else -> - "${ - k.name.replace('_', ' ').split(" ") - .joinToString(" ") { it.replaceFirstChar { char -> char.uppercase() } } - }: $v" } - } - ?.joinToString("\n") ?: "No Local Stats" + notificationManager.createNotificationChannel(channel) + } + // region Public Notification Methods fun updateServiceStateNotification( - summaryString: String? = null, + summaryString: String?, localStats: LocalStats? = null, - currentStatsUpdatedAtMillis: Long? = null, - ) { - notificationManager.notify( - notifyId, + currentStatsUpdatedAtMillis: Long? = System.currentTimeMillis(), + ): Notification { + val notification = createServiceStateNotification( name = summaryString.orEmpty(), message = localStats.formatToString(), nextUpdateAt = currentStatsUpdatedAtMillis?.plus(FIFTEEN_MINUTES_IN_MILLIS), - ), - ) + ) + notificationManager.notify(SERVICE_NOTIFY_ID, notification) + return notification } - fun cancelMessageNotification(contactKey: String) { - notificationManager.cancel(contactKey.hashCode()) + fun updateMessageNotification(contactKey: String, name: String, message: String, isBroadcast: Boolean) { + val notification = createMessageNotification(contactKey, name, message, isBroadcast) + // Use a consistent, unique ID for each message conversation. + notificationManager.notify(contactKey.hashCode(), notification) } - fun updateMessageNotification(contactKey: String, name: String, message: String, isBroadcast: Boolean) = - notificationManager.notify( - contactKey.hashCode(), // show unique notifications, - createMessageNotification(contactKey, name, message, isBroadcast), - ) - fun showAlertNotification(contactKey: String, name: String, alert: String) { - notificationManager.notify( - name.hashCode(), // show unique notifications, - createAlertNotification(contactKey, name, alert), - ) + val notification = createAlertNotification(contactKey, name, alert) + // Use a consistent, unique ID for each alert source. + notificationManager.notify(name.hashCode(), notification) } fun showNewNodeSeenNotification(node: NodeEntity) { - notificationManager.notify( - node.num, // show unique notifications - createNewNodeSeenNotification(node.user.shortName, node.user.longName), - ) + val notification = createNewNodeSeenNotification(node.user.shortName, node.user.longName) + notificationManager.notify(node.num, notification) } fun showOrUpdateLowBatteryNotification(node: NodeEntity, isRemote: Boolean) { - notificationManager.notify( - node.num, // show unique notifications - createLowBatteryNotification(node, isRemote), - ) + val notification = createLowBatteryNotification(node, isRemote) + notificationManager.notify(node.num, notification) } - fun cancelLowBatteryNotification(node: NodeEntity) { - notificationManager.cancel(node.num) + fun showClientNotification(clientNotification: MeshProtos.ClientNotification) { + val notification = + createClientNotification(context.getString(R.string.client_notification), clientNotification.message) + notificationManager.notify(clientNotification.toString().hashCode(), notification) } - fun showClientNotification(notification: MeshProtos.ClientNotification) { - notificationManager.notify( - notification.toString().hashCode(), // show unique notifications - createClientNotification(context.getString(R.string.client_notification), notification.message), - ) - } + fun cancelMessageNotification(contactKey: String) = notificationManager.cancel(contactKey.hashCode()) - fun clearClientNotification(notification: MeshProtos.ClientNotification) { + fun cancelLowBatteryNotification(node: NodeEntity) = notificationManager.cancel(node.num) + + fun clearClientNotification(notification: MeshProtos.ClientNotification) = notificationManager.cancel(notification.toString().hashCode()) - } - private val openAppIntent: PendingIntent by lazy { - PendingIntent.getActivity( - context, - 0, - Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP }, - PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - } + // endregion - private fun createMessageReplyIntent(contactKey: String): Intent = - Intent(context, ReplyReceiver::class.java).apply { - action = ReplyReceiver.REPLY_ACTION - putExtra(ReplyReceiver.CONTACT_KEY, contactKey) - } - - private fun createOpenMessageIntent(contactKey: String): PendingIntent { - val intentFlags = Intent.FLAG_ACTIVITY_SINGLE_TOP - val deepLink = "$DEEP_LINK_BASE_URI/messages/$contactKey" - val deepLinkIntent = - Intent(Intent.ACTION_VIEW, deepLink.toUri(), context, MainActivity::class.java).apply { - flags = intentFlags - } - - val deepLinkPendingIntent: PendingIntent = - TaskStackBuilder.create(context).run { - addNextIntentWithParentStack(deepLinkIntent) - getPendingIntent(0, PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) - } - - return deepLinkPendingIntent - } - - private fun commonBuilder(channel: String, contentIntent: PendingIntent? = null): NotificationCompat.Builder { + // region Notification Creation + private fun createServiceStateNotification(name: String, message: String?, nextUpdateAt: Long?): Notification { val builder = - NotificationCompat.Builder(context, channel) - .setDefaults(NotificationCompat.DEFAULT_ALL) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setContentIntent(contentIntent ?: openAppIntent) + commonBuilder(NotificationType.ServiceState) + .setPriority(NotificationCompat.PRIORITY_MIN) + .setCategory(Notification.CATEGORY_SERVICE) + .setOngoing(true) + .setContentTitle(name) + .setShowWhen(true) - builder.setSmallIcon( - // vector form icons don't work reliably on older androids - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { - R.drawable.app_icon_novect - } else { - R.drawable.app_icon - }, - ) - return builder - } - - lateinit var serviceNotificationBuilder: NotificationCompat.Builder - - fun createServiceStateNotification( - name: String, - message: String? = null, - nextUpdateAt: Long? = null, - ): Notification { - if (!::serviceNotificationBuilder.isInitialized) { - serviceNotificationBuilder = commonBuilder(channelId) + message?.let { + builder.setContentText(it) + builder.setStyle(NotificationCompat.BigTextStyle().bigText(it)) } - with(serviceNotificationBuilder) { - priority = NotificationCompat.PRIORITY_MIN - setCategory(Notification.CATEGORY_SERVICE) - setOngoing(true) - setContentTitle(name) - message?.let { - setContentText(it) - setStyle(NotificationCompat.BigTextStyle().bigText(message)) - } - nextUpdateAt?.let { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - setWhen(it) - setUsesChronometer(true) - setChronometerCountDown(true) - } - } ?: { setWhen(System.currentTimeMillis()) } - setShowWhen(true) + + nextUpdateAt?.let { + builder.setWhen(it) + builder.setUsesChronometer(true) + builder.setChronometerCountDown(true) } - return serviceNotificationBuilder.build() + + return builder.build() } private fun createMessageNotification( @@ -497,139 +285,164 @@ class MeshServiceNotifications(private val context: Context) { message: String, isBroadcast: Boolean, ): Notification { - val channelId = if (isBroadcast) broadcastChannelId else messageChannelId - val messageNotificationBuilder: NotificationCompat.Builder = - commonBuilder(channelId, createOpenMessageIntent(contactKey)) + val type = if (isBroadcast) NotificationType.BroadcastMessage else NotificationType.DirectMessage + val builder = commonBuilder(type, createOpenMessageIntent(contactKey)) val person = Person.Builder().setName(name).build() - // Key for the string that's delivered in the action's intent. - val replyLabel: String = context.getString(R.string.reply) - val remoteInput: RemoteInput = - RemoteInput.Builder(KEY_TEXT_REPLY).run { - setLabel(replyLabel) - build() + val style = NotificationCompat.MessagingStyle(person).addMessage(message, System.currentTimeMillis(), person) + + builder + .setCategory(Notification.CATEGORY_MESSAGE) + .setAutoCancel(true) + .setStyle(style) + .setWhen(System.currentTimeMillis()) + .setShowWhen(true) + + // Only add reply action for direct messages, not broadcasts + if (!isBroadcast) { + builder.addAction(createReplyAction(contactKey)) + } + + return builder.build() + } + + private fun createAlertNotification(contactKey: String, name: String, alert: String): Notification { + val person = Person.Builder().setName(name).build() + val style = NotificationCompat.MessagingStyle(person).addMessage(alert, System.currentTimeMillis(), person) + + return commonBuilder(NotificationType.Alert, createOpenMessageIntent(contactKey)) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(Notification.CATEGORY_ALARM) + .setAutoCancel(true) + .setStyle(style) + .build() + } + + private fun createNewNodeSeenNotification(name: String, message: String?): Notification { + val title = context.getString(R.string.new_node_seen).format(name) + val builder = + commonBuilder(NotificationType.NewNode) + .setCategory(Notification.CATEGORY_STATUS) + .setAutoCancel(true) + .setContentTitle(title) + .setWhen(System.currentTimeMillis()) + .setShowWhen(true) + + message?.let { + builder.setContentText(it) + builder.setStyle(NotificationCompat.BigTextStyle().bigText(it)) + } + return builder.build() + } + + private fun createLowBatteryNotification(node: NodeEntity, isRemote: Boolean): Notification { + val type = if (isRemote) NotificationType.LowBatteryRemote else NotificationType.LowBatteryLocal + val title = context.getString(R.string.low_battery_title).format(node.shortName) + val message = + context.getString(R.string.low_battery_message).format(node.longName, node.deviceMetrics.batteryLevel) + + return commonBuilder(type) + .setCategory(Notification.CATEGORY_STATUS) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setProgress(MAX_BATTERY_LEVEL, node.deviceMetrics.batteryLevel, false) + .setContentTitle(title) + .setContentText(message) + .setStyle(NotificationCompat.BigTextStyle().bigText(message)) + .setWhen(System.currentTimeMillis()) + .setShowWhen(true) + .build() + } + + private fun createClientNotification(name: String, message: String?): Notification = + commonBuilder(NotificationType.Client) + .setCategory(Notification.CATEGORY_ERROR) + .setAutoCancel(true) + .setContentTitle(name) + .apply { + message?.let { + setContentText(it) + setStyle(NotificationCompat.BigTextStyle().bigText(it)) + } + } + .build() + + // endregion + + // region Helper/Builder Methods + private val openAppIntent: PendingIntent by lazy { + val intent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP } + PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + } + + private fun createOpenMessageIntent(contactKey: String): PendingIntent { + val deepLinkUri = "$DEEP_LINK_BASE_URI/messages/$contactKey".toUri() + val deepLinkIntent = + Intent(Intent.ACTION_VIEW, deepLinkUri, context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP } - // Build a PendingIntent for the reply action to trigger. - val replyPendingIntent: PendingIntent = + return TaskStackBuilder.create(context).run { + addNextIntentWithParentStack(deepLinkIntent) + getPendingIntent(contactKey.hashCode(), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + } + } + + private fun createReplyAction(contactKey: String): NotificationCompat.Action { + val replyLabel = context.getString(R.string.reply) + val remoteInput = RemoteInput.Builder(KEY_TEXT_REPLY).setLabel(replyLabel).build() + + val replyIntent = + Intent(context, ReplyReceiver::class.java).apply { + action = ReplyReceiver.REPLY_ACTION + putExtra(ReplyReceiver.CONTACT_KEY, contactKey) + } + val replyPendingIntent = PendingIntent.getBroadcast( context, contactKey.hashCode(), - createMessageReplyIntent(contactKey), + replyIntent, PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) - // Create the reply action and add the remote input. - val action: NotificationCompat.Action = - NotificationCompat.Action.Builder(android.R.drawable.ic_menu_send, replyLabel, replyPendingIntent) - .addRemoteInput(remoteInput) - .build() - with(messageNotificationBuilder) { - priority = NotificationCompat.PRIORITY_DEFAULT - setCategory(Notification.CATEGORY_MESSAGE) - setAutoCancel(true) - setStyle(NotificationCompat.MessagingStyle(person).addMessage(message, System.currentTimeMillis(), person)) - addAction(action) - setWhen(System.currentTimeMillis()) - setShowWhen(true) - } - return messageNotificationBuilder.build() + return NotificationCompat.Action.Builder(android.R.drawable.ic_menu_send, replyLabel, replyPendingIntent) + .addRemoteInput(remoteInput) + .build() } - lateinit var alertNotificationBuilder: NotificationCompat.Builder + private fun commonBuilder( + type: NotificationType, + contentIntent: PendingIntent? = null, + ): NotificationCompat.Builder { + val smallIcon = R.drawable.app_icon - private fun createAlertNotification(contactKey: String, name: String, alert: String): Notification { - if (!::alertNotificationBuilder.isInitialized) { - alertNotificationBuilder = commonBuilder(alertChannelId, createOpenMessageIntent(contactKey)) - } - val person = Person.Builder().setName(name).build() - with(alertNotificationBuilder) { - priority = NotificationCompat.PRIORITY_HIGH - setCategory(Notification.CATEGORY_ALARM) - setAutoCancel(true) - setStyle(NotificationCompat.MessagingStyle(person).addMessage(alert, System.currentTimeMillis(), person)) - } - return alertNotificationBuilder.build() - } - - lateinit var newNodeSeenNotificationBuilder: NotificationCompat.Builder - - private fun createNewNodeSeenNotification(name: String, message: String? = null): Notification { - if (!::newNodeSeenNotificationBuilder.isInitialized) { - newNodeSeenNotificationBuilder = commonBuilder(newNodeChannelId) - } - with(newNodeSeenNotificationBuilder) { - priority = NotificationCompat.PRIORITY_DEFAULT - setCategory(Notification.CATEGORY_STATUS) - setAutoCancel(true) - setContentTitle(context.getString(R.string.new_node_seen).format(name)) - message?.let { - setContentText(it) - setStyle(NotificationCompat.BigTextStyle().bigText(message)) - } - setWhen(System.currentTimeMillis()) - setShowWhen(true) - } - return newNodeSeenNotificationBuilder.build() - } - - lateinit var lowBatteryRemoteNotificationBuilder: NotificationCompat.Builder - lateinit var lowBatteryNotificationBuilder: NotificationCompat.Builder - - private fun createLowBatteryNotification(node: NodeEntity, isRemote: Boolean): Notification { - val tempNotificationBuilder: NotificationCompat.Builder = - if (isRemote) { - if (!::lowBatteryRemoteNotificationBuilder.isInitialized) { - lowBatteryRemoteNotificationBuilder = commonBuilder(lowBatteryChannelId) - } - lowBatteryRemoteNotificationBuilder - } else { - if (!::lowBatteryNotificationBuilder.isInitialized) { - lowBatteryNotificationBuilder = commonBuilder(lowBatteryRemoteChannelId) - } - lowBatteryNotificationBuilder - } - with(tempNotificationBuilder) { - priority = NotificationCompat.PRIORITY_DEFAULT - setCategory(Notification.CATEGORY_STATUS) - setOngoing(true) - setShowWhen(true) - setOnlyAlertOnce(true) - setWhen(System.currentTimeMillis()) - setProgress(MAX_BATTERY_LEVEL, node.deviceMetrics.batteryLevel, false) - setContentTitle(context.getString(R.string.low_battery_title).format(node.shortName)) - val message = - context.getString(R.string.low_battery_message).format(node.longName, node.deviceMetrics.batteryLevel) - message.let { - setContentText(it) - setStyle(NotificationCompat.BigTextStyle().bigText(it)) - } - } - if (isRemote) { - lowBatteryRemoteNotificationBuilder = tempNotificationBuilder - return lowBatteryRemoteNotificationBuilder.build() - } else { - lowBatteryNotificationBuilder = tempNotificationBuilder - return lowBatteryNotificationBuilder.build() - } - } - - lateinit var clientNotificationBuilder: NotificationCompat.Builder - - private fun createClientNotification(name: String, message: String? = null): Notification { - if (!::clientNotificationBuilder.isInitialized) { - clientNotificationBuilder = commonBuilder(clientNotificationChannelId) - } - with(clientNotificationBuilder) { - priority = NotificationCompat.PRIORITY_DEFAULT - setCategory(Notification.CATEGORY_ERROR) - setAutoCancel(true) - setContentTitle(name) - message?.let { - setContentText(it) - setStyle(NotificationCompat.BigTextStyle().bigText(message)) - } - } - return clientNotificationBuilder.build() + return NotificationCompat.Builder(context, type.channelId) + .setSmallIcon(smallIcon) + .setColor(NOTIFICATION_LIGHT_COLOR) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentIntent(contentIntent ?: openAppIntent) } + // endregion +} + +// Extension function to format LocalStats into a readable string. +private fun LocalStats?.formatToString(): String { + if (this == null) return "No Local Stats" + + return this.allFields + .mapNotNull { (k, v) -> + when (k.name) { + "num_online_nodes", + "num_total_nodes", + -> null // Exclude these fields + "uptime_seconds" -> "Uptime: ${formatUptime(v as Int)}" + "channel_utilization" -> "ChUtil: %.2f%%".format(v) + "air_util_tx" -> "AirUtilTX: %.2f%%".format(v) + else -> { + val formattedKey = k.name.replace('_', ' ').replaceFirstChar { it.titlecase() } + "$formattedKey: $v" + } + } + } + .joinToString("\n") } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshServiceStarter.kt b/app/src/main/java/com/geeksville/mesh/service/MeshServiceStarter.kt index 3306b3032..ce690b9de 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceStarter.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceStarter.kt @@ -71,7 +71,7 @@ fun MeshService.Companion.startService(context: Context) { // to Signal or whatever. info("Trying to start service debug=${BuildConfig.DEBUG}") - val intent = createIntent(context) + val intent = createIntent() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { try { context.startForegroundService(intent) diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index 6d0a134c5..93a5e099a 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -593,7 +593,7 @@ private fun TopBarActions( onAction: (Any?) -> Unit, ) { val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle() - val isConnected by viewModel.isConnected.collectAsStateWithLifecycle(false) + val isConnected by viewModel.isConnectedStateFlow.collectAsStateWithLifecycle(false) AnimatedVisibility(ourNode != null && currentDestination?.isTopLevel() == true && isConnected) { ourNode?.let { NodeChip(node = it, isThisNode = true, isConnected = isConnected, onAction = onAction) } } @@ -645,14 +645,12 @@ private fun ConnectionState.getConnectionColor(): Color = when (this) { ConnectionState.CONNECTED -> colorScheme.StatusGreen ConnectionState.DEVICE_SLEEP -> colorScheme.StatusYellow ConnectionState.DISCONNECTED -> colorScheme.StatusRed - ConnectionState.CONNECTING -> colorScheme.StatusYellow } private fun ConnectionState.getConnectionIcon(): ImageVector = when (this) { ConnectionState.CONNECTED -> Icons.TwoTone.CloudDone ConnectionState.DEVICE_SLEEP -> Icons.TwoTone.CloudUpload ConnectionState.DISCONNECTED -> Icons.TwoTone.CloudOff - ConnectionState.CONNECTING -> Icons.TwoTone.CloudUpload } @Composable @@ -660,5 +658,4 @@ private fun ConnectionState.getTooltipString(): String = when (this) { ConnectionState.CONNECTED -> stringResource(R.string.connected) ConnectionState.DEVICE_SLEEP -> stringResource(R.string.device_sleeping) ConnectionState.DISCONNECTED -> stringResource(R.string.disconnected) - ConnectionState.CONNECTING -> stringResource(R.string.connecting_to_device) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt b/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt index 09d9d5141..15ca5e6a8 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/connections/Connections.kt @@ -214,7 +214,6 @@ fun ConnectionsScreen( ConnectionState.DISCONNECTED -> R.string.not_connected ConnectionState.DEVICE_SLEEP -> R.string.connected_sleeping - ConnectionState.CONNECTING -> R.string.connecting_to_device }.let { val firmwareString = info?.firmwareString ?: context.getString(R.string.unknown) scanModel.setErrorText(context.getString(it, firmwareString)) @@ -257,7 +256,7 @@ fun ConnectionsScreen( Spacer(modifier = Modifier.height(8.dp)) - val isConnected by uiViewModel.isConnected.collectAsState(false) + val isConnected by uiViewModel.isConnectedStateFlow.collectAsState(false) val ourNode by uiViewModel.ourNodeInfo.collectAsState() if (isConnected) { ourNode?.let { node -> diff --git a/app/src/main/java/com/geeksville/mesh/ui/map/MapView.kt b/app/src/main/java/com/geeksville/mesh/ui/map/MapView.kt index f337447cb..63df92c70 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/map/MapView.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/map/MapView.kt @@ -442,6 +442,8 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un } } + val isConnected = model.isConnectedStateFlow.collectAsStateWithLifecycle(false) + LaunchedEffect(showCurrentCacheInfo) { if (!showCurrentCacheInfo) return@LaunchedEffect model.showSnackbar(R.string.calculating) @@ -475,7 +477,7 @@ fun MapView(model: UIViewModel = viewModel(), navigateToNodeDetails: (Int) -> Un override fun longPressHelper(p: GeoPoint): Boolean { performHapticFeedback() - val enabled = model.isConnected() && downloadRegionBoundingBox == null + val enabled = isConnected.value && downloadRegionBoundingBox == null if (enabled) { showEditWaypointDialog = waypoint { diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt index 060e94c31..622b68dac 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/Message.kt @@ -141,7 +141,7 @@ internal fun MessageScreen( // State from ViewModel val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle() - val isConnected by viewModel.isConnected.collectAsStateWithLifecycle(initialValue = false) + val isConnected by viewModel.isConnectedStateFlow.collectAsStateWithLifecycle(initialValue = false) val channels by viewModel.channels.collectAsStateWithLifecycle() val quickChatActions by viewModel.quickChatActions.collectAsStateWithLifecycle(initialValue = emptyList()) val messages by viewModel.getMessagesFrom(contactKey).collectAsStateWithLifecycle(initialValue = emptyList()) diff --git a/app/src/main/java/com/geeksville/mesh/ui/message/MessageList.kt b/app/src/main/java/com/geeksville/mesh/ui/message/MessageList.kt index 4ba19713a..e8081febc 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/message/MessageList.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/message/MessageList.kt @@ -70,17 +70,15 @@ fun DeliveryInfo( ) = AlertDialog( onDismissRequest = onDismiss, dismissButton = { - FilledTonalButton( - onClick = onDismiss, - modifier = Modifier.padding(horizontal = 16.dp), - ) { Text(text = stringResource(id = R.string.close)) } + FilledTonalButton(onClick = onDismiss, modifier = Modifier.padding(horizontal = 16.dp)) { + Text(text = stringResource(id = R.string.close)) + } }, confirmButton = { if (resendOption) { - FilledTonalButton( - onClick = onConfirm, - modifier = Modifier.padding(horizontal = 16.dp), - ) { Text(text = stringResource(id = R.string.resend)) } + FilledTonalButton(onClick = onConfirm, modifier = Modifier.padding(horizontal = 16.dp)) { + Text(text = stringResource(id = R.string.resend)) + } } }, title = { @@ -88,7 +86,7 @@ fun DeliveryInfo( text = stringResource(id = title), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, - style = MaterialTheme.typography.headlineSmall + style = MaterialTheme.typography.headlineSmall, ) }, text = { @@ -97,12 +95,12 @@ fun DeliveryInfo( text = stringResource(id = it), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, ) } }, shape = RoundedCornerShape(16.dp), - containerColor = MaterialTheme.colorScheme.surface + containerColor = MaterialTheme.colorScheme.surface, ) @Suppress("LongMethod") @@ -138,7 +136,7 @@ internal fun MessageList( viewModel.sendMessage(msg.text, contactKey) }, onDismiss = { showStatusDialog = null }, - resendOption = msg.status?.equals(MessageStatus.ERROR) ?: false + resendOption = msg.status?.equals(MessageStatus.ERROR) ?: false, ) } @@ -156,19 +154,13 @@ internal fun MessageList( val nodes by viewModel.nodeList.collectAsStateWithLifecycle() val ourNode by viewModel.ourNodeInfo.collectAsStateWithLifecycle() - val isConnected by viewModel.isConnected.collectAsStateWithLifecycle(false) + val isConnected by viewModel.isConnectedStateFlow.collectAsStateWithLifecycle(false) val coroutineScope = rememberCoroutineScope() - LazyColumn( - modifier = modifier.fillMaxSize(), - state = listState, - reverseLayout = true, - ) { + LazyColumn(modifier = modifier.fillMaxSize(), state = listState, reverseLayout = true) { items(messages, key = { it.uuid }) { msg -> if (ourNode != null) { val selected by remember { derivedStateOf { selectedIds.value.contains(msg.uuid) } } - val node by remember { - derivedStateOf { nodes.find { it.num == msg.node.num } ?: msg.node } - } + val node by remember { derivedStateOf { nodes.find { it.num == msg.node.num } ?: msg.node } } MessageItem( modifier = Modifier.animateItem(), @@ -195,7 +187,7 @@ internal fun MessageList( listState.animateScrollToItem(index = targetIndex) } } - } + }, ) } } @@ -203,11 +195,7 @@ internal fun MessageList( } @Composable -private fun AutoScrollToBottom( - listState: LazyListState, - list: List, - itemThreshold: Int = 3, -) = with(listState) { +private fun AutoScrollToBottom(listState: LazyListState, list: List, itemThreshold: Int = 3) = with(listState) { val shouldAutoScroll by remember { derivedStateOf { firstVisibleItemIndex < itemThreshold } } if (shouldAutoScroll) { LaunchedEffect(list) { @@ -220,11 +208,7 @@ private fun AutoScrollToBottom( @OptIn(FlowPreview::class) @Composable -private fun UpdateUnreadCount( - listState: LazyListState, - messages: List, - onUnreadChanged: (Long) -> Unit, -) { +private fun UpdateUnreadCount(listState: LazyListState, messages: List, onUnreadChanged: (Long) -> Unit) { LaunchedEffect(messages) { snapshotFlow { listState.firstVisibleItemIndex } .debounce(timeoutMillis = 500L) diff --git a/app/src/main/java/com/geeksville/mesh/util/Extensions.kt b/app/src/main/java/com/geeksville/mesh/util/Extensions.kt index 039efa58f..9a1067dee 100644 --- a/app/src/main/java/com/geeksville/mesh/util/Extensions.kt +++ b/app/src/main/java/com/geeksville/mesh/util/Extensions.kt @@ -20,22 +20,19 @@ package com.geeksville.mesh.util import android.widget.EditText import com.geeksville.mesh.BuildConfig import com.geeksville.mesh.ConfigProtos +import com.geeksville.mesh.MeshProtos /** - * When printing strings to logs sometimes we want to print useful debugging information about users - * or positions. But we don't want to leak things like usernames or locations. So this function - * if given a string, will return a string which is a maximum of three characters long, taken from the tail - * of the string. Which should effectively hide real usernames and locations, - * but still let us see if values were zero, empty or different. + * When printing strings to logs sometimes we want to print useful debugging information about users or positions. But + * we don't want to leak things like usernames or locations. So this function if given a string, will return a string + * which is a maximum of three characters long, taken from the tail of the string. Which should effectively hide real + * usernames and locations, but still let us see if values were zero, empty or different. */ val Any?.anonymize: String get() = this.anonymize() -/** - * A version of anonymize that allows passing in a custom minimum length - */ -fun Any?.anonymize(maxLen: Int = 3) = - if (this != null) ("..." + this.toString().takeLast(maxLen)) else "null" +/** A version of anonymize that allows passing in a custom minimum length */ +fun Any?.anonymize(maxLen: Int = 3) = if (this != null) ("..." + this.toString().takeLast(maxLen)) else "null" // A toString that makes sure all newlines are removed (for nice logging). fun Any.toOneLineString() = this.toString().replace('\n', ' ') @@ -47,13 +44,19 @@ fun ConfigProtos.Config.toOneLineString(): String { .replace('\n', ' ') } +fun MeshProtos.toOneLineString(): String { + val redactedFields = """(public_key:|private_key:|admin_key:)\s*".*""" // Redact keys + return this.toString() + .replace(redactedFields.toRegex()) { "${it.groupValues[1]} \"[REDACTED]\"" } + .replace('\n', ' ') +} + // Return a one line string version of an object (but if a release build, just say 'might be PII) -fun Any.toPIIString() = - if (!BuildConfig.DEBUG) { - "" - } else { - this.toOneLineString() - } +fun Any.toPIIString() = if (!BuildConfig.DEBUG) { + "" +} else { + this.toOneLineString() +} fun ByteArray.toHexString() = joinToString("") { "%02x".format(it) } @@ -72,7 +75,6 @@ fun formatAgo(lastSeenUnix: Int, currentTimeMillis: Long = System.currentTimeMil // Allows usage like email.onEditorAction(EditorInfo.IME_ACTION_NEXT, { confirm() }) fun EditText.onEditorAction(actionId: Int, func: () -> Unit) { setOnEditorActionListener { _, receivedActionId, _ -> - if (actionId == receivedActionId) { func() }