Replaced a few hardcoded string values with string resources (#2544)

This commit is contained in:
Tristan Waddington 2025-07-28 18:10:02 -07:00 committed by GitHub
parent fe97cbc0ac
commit d02909df5e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 308 additions and 411 deletions

View file

@ -45,9 +45,7 @@ import com.geeksville.mesh.service.ReplyReceiver.Companion.KEY_TEXT_REPLY
import com.geeksville.mesh.util.formatUptime
@Suppress("TooManyFunctions")
class MeshServiceNotifications(
private val context: Context
) {
class MeshServiceNotifications(private val context: Context) {
val notificationLightColor = Color.BLUE
@ -56,7 +54,8 @@ class MeshServiceNotifications(
const val MAX_BATTERY_LEVEL = 100
}
private val notificationManager: NotificationManager get() = context.notificationManager
private val notificationManager: NotificationManager
get() = context.notificationManager
// We have two notification channels: one for general service status and another one for messages
val notifyId = 101
@ -84,14 +83,11 @@ class MeshServiceNotifications(
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
}
val channel =
NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_MIN).apply {
lightColor = notificationLightColor
lockscreenVisibility = Notification.VISIBILITY_PRIVATE
}
notificationManager.createNotificationChannel(channel)
}
return channelId
@ -102,22 +98,19 @@ class MeshServiceNotifications(
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()
)
}
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
@ -128,22 +121,19 @@ class MeshServiceNotifications(
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()
)
}
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
@ -154,31 +144,31 @@ class MeshServiceNotifications(
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 =
(
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()
)
}
"://" +
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
@ -189,22 +179,19 @@ class MeshServiceNotifications(
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()
)
}
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
@ -215,22 +202,19 @@ class MeshServiceNotifications(
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()
)
}
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
@ -242,25 +226,21 @@ class MeshServiceNotifications(
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()
)
}
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
@ -271,15 +251,12 @@ class MeshServiceNotifications(
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)
}
val channel =
NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_HIGH).apply {
lightColor = notificationLightColor
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
setShowBadge(true)
}
notificationManager.createNotificationChannel(channel)
}
return channelId
@ -355,19 +332,23 @@ class MeshServiceNotifications(
}
}
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"
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"
?.joinToString("\n") ?: "No Local Stats"
fun updateServiceStateNotification(
summaryString: String? = null,
@ -379,8 +360,8 @@ class MeshServiceNotifications(
createServiceStateNotification(
name = summaryString.orEmpty(),
message = localStats.formatToString(),
nextUpdateAt = currentStatsUpdatedAtMillis?.plus(FIFTEEN_MINUTES_IN_MILLIS)
)
nextUpdateAt = currentStatsUpdatedAtMillis?.plus(FIFTEEN_MINUTES_IN_MILLIS),
),
)
}
@ -391,27 +372,27 @@ class MeshServiceNotifications(
fun updateMessageNotification(contactKey: String, name: String, message: String, isBroadcast: Boolean) =
notificationManager.notify(
contactKey.hashCode(), // show unique notifications,
createMessageNotification(contactKey, name, message, isBroadcast)
createMessageNotification(contactKey, name, message, isBroadcast),
)
fun showAlertNotification(contactKey: String, name: String, alert: String) {
notificationManager.notify(
name.hashCode(), // show unique notifications,
createAlertNotification(contactKey, name, alert)
createAlertNotification(contactKey, name, alert),
)
}
fun showNewNodeSeenNotification(node: NodeEntity) {
notificationManager.notify(
node.num, // show unique notifications
createNewNodeSeenNotification(node.user.shortName, node.user.longName)
createNewNodeSeenNotification(node.user.shortName, node.user.longName),
)
}
fun showOrUpdateLowBatteryNotification(node: NodeEntity, isRemote: Boolean) {
notificationManager.notify(
node.num, // show unique notifications
createLowBatteryNotification(node, isRemote)
createLowBatteryNotification(node, isRemote),
)
}
@ -422,9 +403,10 @@ class MeshServiceNotifications(
fun showClientNotification(notification: MeshProtos.ClientNotification) {
notificationManager.notify(
notification.toString().hashCode(), // show unique notifications
createClientNotification(context.getString(R.string.client_notification), notification.message)
createClientNotification(context.getString(R.string.client_notification), notification.message),
)
}
fun clearClientNotification(notification: MeshProtos.ClientNotification) {
notificationManager.cancel(notification.toString().hashCode())
}
@ -433,48 +415,40 @@ class MeshServiceNotifications(
PendingIntent.getActivity(
context,
0,
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
},
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_SINGLE_TOP },
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
private fun createMessageReplyIntent(contactKey: String): Intent {
return Intent(context, ReplyReceiver::class.java).apply {
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 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)
}
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 {
val builder = NotificationCompat.Builder(context, channel)
.setDefaults(NotificationCompat.DEFAULT_ALL)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(contentIntent ?: openAppIntent)
private fun commonBuilder(channel: String, contentIntent: PendingIntent? = null): NotificationCompat.Builder {
val builder =
NotificationCompat.Builder(context, channel)
.setDefaults(NotificationCompat.DEFAULT_ALL)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(contentIntent ?: openAppIntent)
builder.setSmallIcon(
// vector form icons don't work reliably on older androids
@ -482,16 +456,17 @@ class MeshServiceNotifications(
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
nextUpdateAt: Long? = null,
): Notification {
if (!::serviceNotificationBuilder.isInitialized) {
serviceNotificationBuilder = commonBuilder(channelId)
@ -503,10 +478,7 @@ class MeshServiceNotifications(
setContentTitle(name)
message?.let {
setContentText(it)
setStyle(
NotificationCompat.BigTextStyle()
.bigText(message),
)
setStyle(NotificationCompat.BigTextStyle().bigText(message))
}
nextUpdateAt?.let {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
@ -514,9 +486,7 @@ class MeshServiceNotifications(
setUsesChronometer(true)
setChronometerCountDown(true)
}
} ?: {
setWhen(System.currentTimeMillis())
}
} ?: { setWhen(System.currentTimeMillis()) }
setShowWhen(true)
}
return serviceNotificationBuilder.build()
@ -535,10 +505,11 @@ class MeshServiceNotifications(
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 remoteInput: RemoteInput =
RemoteInput.Builder(KEY_TEXT_REPLY).run {
setLabel(replyLabel)
build()
}
// Build a PendingIntent for the reply action to trigger.
val replyPendingIntent: PendingIntent =
@ -546,23 +517,19 @@ class MeshServiceNotifications(
context,
contactKey.hashCode(),
createMessageReplyIntent(contactKey),
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
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()
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)
)
setStyle(NotificationCompat.MessagingStyle(person).addMessage(message, System.currentTimeMillis(), person))
addAction(action)
setWhen(System.currentTimeMillis())
setShowWhen(true)
@ -571,29 +538,23 @@ class MeshServiceNotifications(
}
lateinit var alertNotificationBuilder: NotificationCompat.Builder
private fun createAlertNotification(
contactKey: String,
name: String,
alert: String
): Notification {
private fun createAlertNotification(contactKey: String, name: String, alert: String): Notification {
if (!::alertNotificationBuilder.isInitialized) {
alertNotificationBuilder =
commonBuilder(alertChannelId, createOpenMessageIntent(contactKey))
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)
)
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)
@ -602,13 +563,10 @@ class MeshServiceNotifications(
priority = NotificationCompat.PRIORITY_DEFAULT
setCategory(Notification.CATEGORY_STATUS)
setAutoCancel(true)
setContentTitle("New Node Seen: $name")
setContentTitle(context.getString(R.string.new_node_seen).format(name))
message?.let {
setContentText(it)
setStyle(
NotificationCompat.BigTextStyle()
.bigText(message),
)
setStyle(NotificationCompat.BigTextStyle().bigText(message))
}
setWhen(System.currentTimeMillis())
setShowWhen(true)
@ -618,18 +576,20 @@ class MeshServiceNotifications(
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)
val tempNotificationBuilder: NotificationCompat.Builder =
if (isRemote) {
if (!::lowBatteryRemoteNotificationBuilder.isInitialized) {
lowBatteryRemoteNotificationBuilder = commonBuilder(lowBatteryChannelId)
}
lowBatteryRemoteNotificationBuilder
} else {
if (!::lowBatteryNotificationBuilder.isInitialized) {
lowBatteryNotificationBuilder = commonBuilder(lowBatteryRemoteChannelId)
}
lowBatteryNotificationBuilder
}
lowBatteryRemoteNotificationBuilder
} else {
if (!::lowBatteryNotificationBuilder.isInitialized) {
lowBatteryNotificationBuilder = commonBuilder(lowBatteryRemoteChannelId)
}
lowBatteryNotificationBuilder
}
with(tempNotificationBuilder) {
priority = NotificationCompat.PRIORITY_DEFAULT
setCategory(Notification.CATEGORY_STATUS)
@ -638,21 +598,12 @@ class MeshServiceNotifications(
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
)
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),
)
setStyle(NotificationCompat.BigTextStyle().bigText(it))
}
}
if (isRemote) {
@ -677,10 +628,7 @@ class MeshServiceNotifications(
setContentTitle(name)
message?.let {
setContentText(it)
setStyle(
NotificationCompat.BigTextStyle()
.bigText(message),
)
setStyle(NotificationCompat.BigTextStyle().bigText(message))
}
}
return clientNotificationBuilder.build()

View file

@ -28,27 +28,28 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
import com.geeksville.mesh.util.DistanceUnit
import com.geeksville.mesh.util.toDistanceString
import kotlin.math.pow
import kotlin.math.roundToInt
private const val PositionEnabled = 32
private const val PositionDisabled = 0
private const val POSITION_ENABLED = 32
private const val POSITION_DISABLED = 0
private const val PositionPrecisionMin = 10
private const val PositionPrecisionMax = 19
private const val PositionPrecisionDefault = 13
private const val POSITION_PRECISION_MIN = 10
private const val POSITION_PRECISION_MAX = 19
private const val POSITION_PRECISION_DEFAULT = 13
@Suppress("MagicNumber")
fun precisionBitsToMeters(bits: Int): Double = 23905787.925008 * 0.5.pow(bits.toDouble())
@Composable
fun PositionPrecisionPreference(
title: String,
value: Int,
enabled: Boolean,
onValueChanged: (Int) -> Unit,
@ -58,37 +59,35 @@ fun PositionPrecisionPreference(
Column(modifier = modifier) {
SwitchPreference(
title = title,
checked = value != PositionDisabled,
title = stringResource(R.string.position_enabled),
checked = value != POSITION_DISABLED,
enabled = enabled,
onCheckedChange = { enabled ->
val newValue = if (enabled) PositionEnabled else PositionDisabled
val newValue = if (enabled) POSITION_ENABLED else POSITION_DISABLED
onValueChanged(newValue)
},
padding = PaddingValues(0.dp)
padding = PaddingValues(0.dp),
)
AnimatedVisibility(visible = value != PositionDisabled) {
AnimatedVisibility(visible = value != POSITION_DISABLED) {
SwitchPreference(
title = "Precise location",
checked = value == PositionEnabled,
title = stringResource(R.string.precise_location),
checked = value == POSITION_ENABLED,
enabled = enabled,
onCheckedChange = { enabled ->
val newValue = if (enabled) PositionEnabled else PositionPrecisionDefault
val newValue = if (enabled) POSITION_ENABLED else POSITION_PRECISION_DEFAULT
onValueChanged(newValue)
},
padding = PaddingValues(0.dp)
padding = PaddingValues(0.dp),
)
}
AnimatedVisibility(visible = value in (PositionDisabled + 1)..<PositionEnabled) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
AnimatedVisibility(visible = value in (POSITION_DISABLED + 1)..<POSITION_ENABLED) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Slider(
value = value.toFloat(),
onValueChange = { onValueChanged(it.roundToInt()) },
enabled = enabled,
valueRange = PositionPrecisionMin.toFloat()..PositionPrecisionMax.toFloat(),
steps = PositionPrecisionMax - PositionPrecisionMin - 1,
valueRange = POSITION_PRECISION_MIN.toFloat()..POSITION_PRECISION_MAX.toFloat(),
steps = POSITION_PRECISION_MAX - POSITION_PRECISION_MIN - 1,
)
val precisionMeters = precisionBitsToMeters(value).toInt()
@ -108,10 +107,9 @@ fun PositionPrecisionPreference(
@Composable
private fun PositionPrecisionPreferencePreview() {
PositionPrecisionPreference(
title = "Position enabled",
value = PositionPrecisionDefault,
value = POSITION_PRECISION_DEFAULT,
enabled = true,
onValueChanged = {},
modifier = Modifier.padding(horizontal = 16.dp)
modifier = Modifier.padding(horizontal = 16.dp),
)
}

View file

@ -82,34 +82,29 @@ private fun RowScope.PositionText(text: String, weight: Float) {
)
}
private const val Weight10 = .10f
private const val Weight15 = .15f
private const val Weight20 = .20f
private const val Weight40 = .40f
private const val WEIGHT_10 = .10f
private const val WEIGHT_15 = .15f
private const val WEIGHT_20 = .20f
private const val WEIGHT_40 = .40f
@Composable
private fun HeaderItem(compactWidth: Boolean) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
PositionText(stringResource(R.string.latitude), Weight20)
PositionText(stringResource(R.string.longitude), Weight20)
PositionText(stringResource(R.string.sats), Weight10)
PositionText(stringResource(R.string.alt), Weight15)
Row(modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = Arrangement.SpaceBetween) {
PositionText(stringResource(R.string.latitude), WEIGHT_20)
PositionText(stringResource(R.string.longitude), WEIGHT_20)
PositionText(stringResource(R.string.sats), WEIGHT_10)
PositionText(stringResource(R.string.alt), WEIGHT_15)
if (!compactWidth) {
PositionText("Speed", Weight15)
PositionText(stringResource(R.string.heading), Weight15)
PositionText(stringResource(R.string.speed), WEIGHT_15)
PositionText(stringResource(R.string.heading), WEIGHT_15)
}
PositionText(stringResource(R.string.timestamp), Weight40)
PositionText(stringResource(R.string.timestamp), WEIGHT_40)
}
}
private const val DegD = 1e-7
private const val HeadingDeg = 1e-5
private const val SecondsToMillis = 1000L
private const val DEG_D = 1e-7
private const val HEADING_DEG = 1e-5
private const val SECONDS_TO_MILLIS = 1000L
@Composable
private fun PositionItem(
@ -119,36 +114,32 @@ private fun PositionItem(
system: DisplayUnits,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
PositionText("%.5f".format(position.latitudeI * DegD), Weight20)
PositionText("%.5f".format(position.longitudeI * DegD), Weight20)
PositionText(position.satsInView.toString(), Weight10)
PositionText(position.altitude.metersIn(system).toString(system), Weight15)
PositionText("%.5f".format(position.latitudeI * DEG_D), WEIGHT_20)
PositionText("%.5f".format(position.longitudeI * DEG_D), WEIGHT_20)
PositionText(position.satsInView.toString(), WEIGHT_10)
PositionText(position.altitude.metersIn(system).toString(system), WEIGHT_15)
if (!compactWidth) {
PositionText("${position.groundSpeed} Km/h", Weight15)
PositionText("%.0f°".format(position.groundTrack * HeadingDeg), Weight15)
PositionText("${position.groundSpeed} Km/h", WEIGHT_15)
PositionText("%.0f°".format(position.groundTrack * HEADING_DEG), WEIGHT_15)
}
PositionText(formatPositionTime(position, dateFormat), Weight40)
PositionText(formatPositionTime(position, dateFormat), WEIGHT_40)
}
}
@Composable
private fun formatPositionTime(
position: MeshProtos.Position,
dateFormat: DateFormat
): String {
private fun formatPositionTime(position: MeshProtos.Position, dateFormat: DateFormat): String {
val currentTime = System.currentTimeMillis()
val sixMonthsAgo = currentTime - 180.days.inWholeMilliseconds
val isOlderThanSixMonths = position.time * SecondsToMillis < sixMonthsAgo
val timeText = if (isOlderThanSixMonths) {
stringResource(id = R.string.unknown_age)
} else {
dateFormat.format(position.time * SecondsToMillis)
}
val isOlderThanSixMonths = position.time * SECONDS_TO_MILLIS < sixMonthsAgo
val timeText =
if (isOlderThanSixMonths) {
stringResource(id = R.string.unknown_age)
} else {
dateFormat.format(position.time * SECONDS_TO_MILLIS)
}
return timeText
}
@ -161,9 +152,7 @@ private fun ActionButtons(
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
modifier = modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
@ -171,63 +160,43 @@ private fun ActionButtons(
modifier = Modifier.weight(1f),
onClick = onClear,
enabled = clearButtonEnabled,
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
)
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(id = R.string.clear),
)
Icon(imageVector = Icons.Default.Delete, contentDescription = stringResource(id = R.string.clear))
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(id = R.string.clear),
)
Text(text = stringResource(id = R.string.clear))
}
OutlinedButton(
modifier = Modifier.weight(1f),
onClick = onSave,
enabled = saveButtonEnabled,
) {
Icon(
imageVector = Icons.Default.Save,
contentDescription = stringResource(id = R.string.save),
)
OutlinedButton(modifier = Modifier.weight(1f), onClick = onSave, enabled = saveButtonEnabled) {
Icon(imageVector = Icons.Default.Save, contentDescription = stringResource(id = R.string.save))
Spacer(Modifier.width(8.dp))
Text(
text = stringResource(id = R.string.save),
)
Text(text = stringResource(id = R.string.save))
}
}
}
@Composable
fun PositionLogScreen(
viewModel: MetricsViewModel = hiltViewModel(),
) {
fun PositionLogScreen(viewModel: MetricsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
val exportPositionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode == Activity.RESULT_OK) {
it.data?.data?.let { uri -> viewModel.savePositionCSV(uri) }
val exportPositionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
it.data?.data?.let { uri -> viewModel.savePositionCSV(uri) }
}
}
}
var clearButtonEnabled by rememberSaveable(state.positionLogs) {
mutableStateOf(state.positionLogs.isNotEmpty())
}
var clearButtonEnabled by rememberSaveable(state.positionLogs) { mutableStateOf(state.positionLogs.isNotEmpty()) }
BoxWithConstraints {
val compactWidth = maxWidth < 600.dp
Column {
val textStyle = if (compactWidth) {
MaterialTheme.typography.bodySmall
} else {
LocalTextStyle.current
}
val textStyle =
if (compactWidth) {
MaterialTheme.typography.bodySmall
} else {
LocalTextStyle.current
}
CompositionLocalProvider(LocalTextStyle provides textStyle) {
HeaderItem(compactWidth)
PositionList(compactWidth, state.positionLogs, state.displayUnits)
@ -241,11 +210,12 @@ fun PositionLogScreen(
},
saveButtonEnabled = state.hasPositionLogs(),
onSave = {
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/*"
putExtra(Intent.EXTRA_TITLE, "position.csv")
}
val intent =
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/*"
putExtra(Intent.EXTRA_TITLE, "position.csv")
}
exportPositionLauncher.launch(intent)
},
)
@ -259,28 +229,24 @@ private fun ColumnScope.PositionList(
positions: List<MeshProtos.Position>,
displayUnits: DisplayUnits,
) {
val dateFormat = remember {
DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM)
}
val dateFormat = remember { DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM) }
LazyColumn(
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
) {
items(positions) { position ->
PositionItem(compactWidth, position, dateFormat, displayUnits)
}
LazyColumn(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
items(positions) { position -> PositionItem(compactWidth, position, dateFormat, displayUnits) }
}
}
@Suppress("MagicNumber")
private val testPosition = MeshProtos.Position.newBuilder().apply {
latitudeI = 297604270
longitudeI = -953698040
altitude = 1230
satsInView = 7
time = (System.currentTimeMillis() / 1000).toInt()
}.build()
private val testPosition =
MeshProtos.Position.newBuilder()
.apply {
latitudeI = 297604270
longitudeI = -953698040
altitude = 1230
satsInView = 7
time = (System.currentTimeMillis() / 1000).toInt()
}
.build()
@Preview(showBackground = true)
@Composable
@ -300,12 +266,7 @@ private fun PositionItemPreview() {
private fun ActionButtonsPreview() {
AppTheme {
Column(Modifier.fillMaxSize(), Arrangement.Bottom) {
ActionButtons(
clearButtonEnabled = true,
onClear = {},
saveButtonEnabled = true,
onSave = {},
)
ActionButtons(clearButtonEnabled = true, onClear = {}, saveButtonEnabled = true, onSave = {})
}
}
}

View file

@ -77,15 +77,15 @@ fun EditChannelDialog(
maxSize = 11, // name max_size:12
enabled = true,
isError = false,
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Text, imeAction = ImeAction.Done
),
keyboardOptions =
KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
onValueChanged = {
channelInput = channelInput.copy {
name = it.trim()
if (psk == Channel.default.settings.psk) psk = Channel.getRandomKey()
}
channelInput =
channelInput.copy {
name = it.trim()
if (psk == Channel.default.settings.psk) psk = Channel.getRandomKey()
}
},
onFocusChanged = { isFocused = it.isFocused },
)
@ -101,33 +101,26 @@ fun EditChannelDialog(
channelInput = channelInput.copy { psk = it }
}
},
onGenerateKey = {
channelInput = channelInput.copy { psk = Channel.getRandomKey() }
},
onGenerateKey = { channelInput = channelInput.copy { psk = Channel.getRandomKey() } },
)
SwitchPreference(
title = stringResource(R.string.uplink_enabled),
checked = channelInput.uplinkEnabled,
enabled = true,
onCheckedChange = {
channelInput = channelInput.copy { uplinkEnabled = it }
},
padding = PaddingValues(0.dp)
onCheckedChange = { channelInput = channelInput.copy { uplinkEnabled = it } },
padding = PaddingValues(0.dp),
)
SwitchPreference(
title = stringResource(R.string.downlink_enabled),
checked = channelInput.downlinkEnabled,
enabled = true,
onCheckedChange = {
channelInput = channelInput.copy { downlinkEnabled = it }
},
padding = PaddingValues(0.dp)
onCheckedChange = { channelInput = channelInput.copy { downlinkEnabled = it } },
padding = PaddingValues(0.dp),
)
PositionPrecisionPreference(
title = stringResource(R.string.position_enabled),
enabled = true,
value = channelInput.moduleSettings.positionPrecision,
onValueChanged = {
@ -139,24 +132,17 @@ fun EditChannelDialog(
},
confirmButton = {
FlowRow(
modifier = modifier
.fillMaxWidth()
.padding(start = 24.dp, end = 24.dp, bottom = 16.dp),
modifier = modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
TextButton(
modifier = modifier.weight(1f),
onClick = onDismissRequest
) { Text(stringResource(R.string.cancel)) }
Button(
modifier = modifier.weight(1f),
onClick = {
onAddClick(channelInput)
},
enabled = true,
) { Text(stringResource(R.string.save)) }
TextButton(modifier = modifier.weight(1f), onClick = onDismissRequest) {
Text(stringResource(R.string.cancel))
}
Button(modifier = modifier.weight(1f), onClick = { onAddClick(channelInput) }, enabled = true) {
Text(stringResource(R.string.save))
}
}
}
},
)
}
@ -164,11 +150,12 @@ fun EditChannelDialog(
@Composable
private fun EditChannelDialogPreview() {
EditChannelDialog(
channelSettings = channelSettings {
channelSettings =
channelSettings {
psk = Channel.default.settings.psk
name = Channel.default.name
},
onAddClick = { },
onDismissRequest = { },
onAddClick = {},
onDismissRequest = {},
)
}

View file

@ -138,6 +138,7 @@
<string name="pairing_failed_try_again">Pairing failed, please select again</string>
<string name="location_disabled">Location access is turned off, can not provide position to mesh.</string>
<string name="share">Share</string>
<string name="new_node_seen">New Node Seen: %s</string>
<string name="disconnected">Disconnected</string>
<string name="device_sleeping">Device sleeping</string>
<string name="connected_count">Connected: %1$s online</string>
@ -435,6 +436,7 @@
<string name="downlink_enabled">Downlink enabled</string>
<string name="default_">Default</string>
<string name="position_enabled">Position enabled</string>
<string name="precise_location">Precise location</string>
<string name="gpio_pin">GPIO pin</string>
<string name="type">Type</string>
<string name="hide_password">Hide password</string>
@ -653,6 +655,7 @@
<string name="firmware_version">Firmware version</string>
<string name="timestamp">Timestamp</string>
<string name="heading">Heading</string>
<string name="speed">Speed</string>
<string name="sats">Sats</string>
<string name="alt">Alt</string>
<string name="freq">Freq</string>