revert(service): reverts a bunch of changes to MeshService.kt (#2692)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2025-08-12 17:19:40 -05:00 committed by GitHub
parent 1923b6d6d4
commit a35e43d979
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1622 additions and 1603 deletions

View file

@ -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() {

View file

@ -124,6 +124,11 @@ constructor(
nodeInfoDao.clearNodeInfo()
}
suspend fun installNodeDb(nodes: List<NodeEntity>) = withContext(dispatchers.io) {
nodeInfoDao.clearNodeInfo()
nodeInfoDao.putAll(nodes)
}
suspend fun clearNodeDB() = withContext(dispatchers.io) { nodeInfoDao.clearNodeInfo() }
suspend fun deleteNode(num: Int) = withContext(dispatchers.io) {

View file

@ -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<AppOnlyProtos.ChannelSet?>(null)
val requestChannelSet: StateFlow<AppOnlyProtos.ChannelSet?>

View file

@ -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<RadioConfigRoutes.RadioConfig>() ||
this.hasRoute<NodesRoutes.NodeDetail>() ||
this.isConfigRoute() ||
this.isNodeDetailRoute()
)
}
fun NavDestination.showLongNameTitle(): Boolean = !this.isTopLevel() &&
(
this.hasRoute<RadioConfigRoutes.RadioConfig>() ||
this.hasRoute<NodesRoutes.NodeDetail>() ||
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)

View file

@ -91,6 +91,10 @@ constructor(
nodeDB.installMyNodeInfo(mi)
}
suspend fun installNodeDb(nodes: List<NodeEntity>) {
nodeDB.installNodeDb(nodes)
}
suspend fun insertMetadata(fromNum: Int, metadata: DeviceMetadata) {
nodeDB.insertMetadata(MetadataEntity(fromNum, metadata))
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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> = _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}")
}
}
}
}
}

View file

@ -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
}

File diff suppressed because it is too large Load diff

View file

@ -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<NotificationManager>()!!
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")
}

View file

@ -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)

View file

@ -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)
}

View file

@ -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 ->

View file

@ -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 {

View file

@ -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())

View file

@ -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 <T> AutoScrollToBottom(
listState: LazyListState,
list: List<T>,
itemThreshold: Int = 3,
) = with(listState) {
private fun <T> AutoScrollToBottom(listState: LazyListState, list: List<T>, itemThreshold: Int = 3) = with(listState) {
val shouldAutoScroll by remember { derivedStateOf { firstVisibleItemIndex < itemThreshold } }
if (shouldAutoScroll) {
LaunchedEffect(list) {
@ -220,11 +208,7 @@ private fun <T> AutoScrollToBottom(
@OptIn(FlowPreview::class)
@Composable
private fun UpdateUnreadCount(
listState: LazyListState,
messages: List<Message>,
onUnreadChanged: (Long) -> Unit,
) {
private fun UpdateUnreadCount(listState: LazyListState, messages: List<Message>, onUnreadChanged: (Long) -> Unit) {
LaunchedEffect(messages) {
snapshotFlow { listState.firstVisibleItemIndex }
.debounce(timeoutMillis = 500L)

View file

@ -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) {
"<PII?>"
} else {
this.toOneLineString()
}
fun Any.toPIIString() = if (!BuildConfig.DEBUG) {
"<PII?>"
} 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()
}