diff --git a/app/src/main/java/com/geeksville/mesh/DataPacket.kt b/app/src/main/java/com/geeksville/mesh/DataPacket.kt
index 690816b6c..94c6d6f61 100644
--- a/app/src/main/java/com/geeksville/mesh/DataPacket.kt
+++ b/app/src/main/java/com/geeksville/mesh/DataPacket.kt
@@ -85,6 +85,13 @@ data class DataPacket(
null
}
+ val alert: String?
+ get() = if (dataType == Portnums.PortNum.ALERT_APP_VALUE) {
+ bytes?.decodeToString()
+ } else {
+ null
+ }
+
constructor(to: String?, channel: Int, waypoint: MeshProtos.Waypoint) : this(
to = to,
bytes = waypoint.toByteArray(),
diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt
index a73793ba3..a10036854 100644
--- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt
+++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt
@@ -25,10 +25,13 @@ import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.hardware.usb.UsbManager
import android.net.Uri
+import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.RemoteException
+import android.provider.Settings
+import android.text.Html
import android.text.method.LinkMovementMethod
import android.view.Menu
import android.view.MenuItem
@@ -43,7 +46,9 @@ import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.widget.Toolbar
import androidx.compose.runtime.getValue
import androidx.core.content.ContextCompat
+import androidx.core.content.edit
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
+import androidx.core.view.setPadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentTransaction
@@ -177,6 +182,7 @@ class MainActivity : AppCompatActivity(), Logging {
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
if (result.entries.all { it.value }) {
info("Notification permissions granted")
+ checkAlertDnD()
} else {
warn("Notification permissions denied")
showSnackbar(getString(R.string.notification_denied), Snackbar.LENGTH_SHORT)
@@ -427,16 +433,58 @@ class MainActivity : AppCompatActivity(), Logging {
service.startProvideLocation()
}
}
+ checkNotificationPermissions()
+ }
+ }
- if (!hasNotificationPermission()) {
- val notificationPermissions = getNotificationPermissions()
- rationaleDialog(
- shouldShowRequestPermissionRationale(notificationPermissions),
- R.string.notification_required,
- getString(R.string.why_notification_required),
- ) {
- notificationPermissionsLauncher.launch(notificationPermissions)
+ private fun checkNotificationPermissions() {
+ if (!hasNotificationPermission()) {
+ val notificationPermissions = getNotificationPermissions()
+ rationaleDialog(
+ shouldShowRequestPermissionRationale(notificationPermissions),
+ R.string.notification_required,
+ getString(R.string.why_notification_required),
+ ) {
+ notificationPermissionsLauncher.launch(notificationPermissions)
+ }
+ }
+ }
+
+ private fun checkAlertDnD() {
+ if (
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
+ ) {
+ val prefs = UIViewModel.getPreferences(this)
+ val rationaleShown = prefs.getBoolean("dnd_rationale_shown", false)
+ if (!rationaleShown && hasNotificationPermission()) {
+ fun showAlertAppNotificationSettings() {
+ val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS)
+ intent.putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
+ intent.putExtra(Settings.EXTRA_CHANNEL_ID, "my_alerts")
+ startActivity(intent)
}
+ val message = Html.fromHtml(
+ getString(R.string.alerts_dnd_request_text),
+ Html.FROM_HTML_MODE_COMPACT
+ )
+ val messageTextView = TextView(this).also {
+ it.text = message
+ it.movementMethod = LinkMovementMethod.getInstance()
+ it.setPadding(resources.getDimension(R.dimen.margin_normal).toInt())
+ }
+ MaterialAlertDialogBuilder(this)
+ .setTitle(R.string.alerts_dnd_request_title)
+ .setView(messageTextView)
+ .setNeutralButton(R.string.cancel) { dialog, _ ->
+ prefs.edit { putBoolean("dnd_rationale_shown", true) }
+ dialog.dismiss()
+ }
+ .setPositiveButton(R.string.channel_settings) { dialog, _ ->
+ showAlertAppNotificationSettings()
+ prefs.edit { putBoolean("dnd_rationale_shown", true) }
+ dialog.dismiss()
+ }
+ .setCancelable(false).show()
}
}
}
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 14f7a96a9..6110b3ad6 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt
@@ -301,6 +301,14 @@ class MeshService : Service(), Logging {
startPacketQueue()
}
+ private fun showAlertNotification(contactKey: String, dataPacket: DataPacket) {
+ serviceNotifications.showAlertNotification(
+ contactKey,
+ getSenderName(dataPacket),
+ dataPacket.alert ?: getString(R.string.critical_alert)
+ )
+ }
+
private fun updateMessageNotification(contactKey: String, dataPacket: DataPacket) {
val message: String = when (dataPacket.dataType) {
Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> dataPacket.text!!
@@ -317,7 +325,7 @@ class MeshService : Service(), Logging {
super.onCreate()
info("Creating mesh service")
-
+ serviceNotifications.initChannels()
// Switch to the IO thread
serviceScope.handledLaunch {
radioInterfaceService.connect()
@@ -656,6 +664,7 @@ class MeshService : Service(), Logging {
private val rememberDataType = setOf(
Portnums.PortNum.TEXT_MESSAGE_APP_VALUE,
+ Portnums.PortNum.ALERT_APP_VALUE,
Portnums.PortNum.WAYPOINT_APP_VALUE,
)
@@ -692,7 +701,11 @@ class MeshService : Service(), Logging {
packetRepository.get().apply {
insert(packetToSave)
val isMuted = getContactSettings(contactKey).isMuted
- if (updateNotification && !isMuted) updateMessageNotification(contactKey, dataPacket)
+ if (packetToSave.port_num == Portnums.PortNum.ALERT_APP_VALUE && !isMuted) {
+ showAlertNotification(contactKey, dataPacket)
+ } else if (updateNotification && !isMuted) {
+ updateMessageNotification(contactKey, dataPacket)
+ }
}
}
}
@@ -730,6 +743,11 @@ class MeshService : Service(), Logging {
}
}
+ Portnums.PortNum.ALERT_APP_VALUE -> {
+ debug("Received ALERT_APP from $fromId")
+ rememberDataPacket(dataPacket)
+ }
+
Portnums.PortNum.WAYPOINT_APP_VALUE -> {
val u = MeshProtos.Waypoint.parseFrom(data.payload)
// Validate locked Waypoints from the original sender
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 98e457917..8ffaee120 100644
--- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt
+++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt
@@ -21,6 +21,7 @@ import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
+import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.graphics.Color
@@ -30,6 +31,7 @@ import android.os.Build
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
+import androidx.core.net.toUri
import com.geeksville.mesh.MainActivity
import com.geeksville.mesh.R
import com.geeksville.mesh.TelemetryProtos.LocalStats
@@ -42,10 +44,13 @@ class MeshServiceNotifications(
private val context: Context
) {
+ val notificationLightColor = Color.BLUE
+
companion object {
private const val FIFTEEN_MINUTES_IN_MILLIS = 15L * 60 * 1000
const val OPEN_MESSAGE_ACTION = "com.geeksville.mesh.OPEN_MESSAGE_ACTION"
- const val OPEN_MESSAGE_EXTRA_CONTACT_KEY = "com.geeksville.mesh.OPEN_MESSAGE_EXTRA_CONTACT_KEY"
+ const val OPEN_MESSAGE_EXTRA_CONTACT_KEY =
+ "com.geeksville.mesh.OPEN_MESSAGE_EXTRA_CONTACT_KEY"
}
private val notificationManager: NotificationManager get() = context.notificationManager
@@ -53,67 +58,118 @@ class MeshServiceNotifications(
// We have two notification channels: one for general service status and another one for messages
val notifyId = 101
+ fun initChannels() {
+ // create notification channels on service creation
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ createNotificationChannel()
+ createMessageNotificationChannel()
+ createAlertNotificationChannel()
+ createNewNodeNotificationChannel()
+ }
+ }
+
@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel(): String {
val channelId = "my_service"
- val channelName = context.getString(R.string.meshtastic_service_notifications)
- val channel = NotificationChannel(
- channelId,
- channelName,
- NotificationManager.IMPORTANCE_MIN
- ).apply {
- lightColor = Color.BLUE
- lockscreenVisibility = Notification.VISIBILITY_PRIVATE
+ 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
+ }
+ notificationManager.createNotificationChannel(channel)
}
- notificationManager.createNotificationChannel(channel)
return channelId
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createMessageNotificationChannel(): String {
val channelId = "my_messages"
- val channelName = context.getString(R.string.meshtastic_messages_notifications)
- val channel = NotificationChannel(
- channelId,
- channelName,
- NotificationManager.IMPORTANCE_HIGH
- ).apply {
- lightColor = Color.BLUE
- lockscreenVisibility = Notification.VISIBILITY_PUBLIC
- setShowBadge(true)
- setSound(
- RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION),
- AudioAttributes.Builder()
- .setUsage(AudioAttributes.USAGE_NOTIFICATION)
- .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
- .build()
- )
+ 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 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)
}
- notificationManager.createNotificationChannel(channel)
return channelId
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createNewNodeNotificationChannel(): String {
val channelId = "new_nodes"
- val channelName = context.getString(R.string.meshtastic_new_nodes_notifications)
- val channel = NotificationChannel(
- channelId,
- channelName,
- NotificationManager.IMPORTANCE_HIGH
- ).apply {
- lightColor = Color.BLUE
- lockscreenVisibility = Notification.VISIBILITY_PUBLIC
- setShowBadge(true)
- setSound(
- RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION),
- AudioAttributes.Builder()
- .setUsage(AudioAttributes.USAGE_NOTIFICATION)
- .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
- .build()
- )
+ 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)
}
- notificationManager.createNotificationChannel(channel)
return channelId
}
@@ -137,6 +193,14 @@ class MeshServiceNotifications(
}
}
+ 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()
@@ -151,7 +215,8 @@ class MeshServiceNotifications(
"uptime_seconds" -> "Uptime: ${formatUptime(v as Int)}"
"channel_utilization" -> "ChUtil: %.2f%%".format(v)
"air_util_tx" -> "AirUtilTX: %.2f%%".format(v)
- else -> "${
+ else ->
+ "${
k.name.replace('_', ' ').split(" ")
.joinToString(" ") { it.replaceFirstChar { char -> char.uppercase() } }
}: $v"
@@ -179,6 +244,13 @@ class MeshServiceNotifications(
createMessageNotification(contactKey, name, message)
)
+ fun showAlertNotification(contactKey: String, name: String, alert: String) {
+ notificationManager.notify(
+ name.hashCode(), // show unique notifications,
+ createAlertNotification(contactKey, name, alert)
+ )
+ }
+
fun showNewNodeSeenNotification(node: NodeEntity) {
notificationManager.notify(
node.num, // show unique notifications
@@ -268,7 +340,11 @@ class MeshServiceNotifications(
}
lateinit var messageNotificationBuilder: NotificationCompat.Builder
- private fun createMessageNotification(contactKey: String, name: String, message: String): Notification {
+ private fun createMessageNotification(
+ contactKey: String,
+ name: String,
+ message: String
+ ): Notification {
if (!::messageNotificationBuilder.isInitialized) {
messageNotificationBuilder = commonBuilder(messageChannelId)
}
@@ -279,7 +355,8 @@ class MeshServiceNotifications(
setCategory(Notification.CATEGORY_MESSAGE)
setAutoCancel(true)
setStyle(
- NotificationCompat.MessagingStyle(person).addMessage(message, System.currentTimeMillis(), person)
+ NotificationCompat.MessagingStyle(person)
+ .addMessage(message, System.currentTimeMillis(), person)
)
setWhen(System.currentTimeMillis())
setShowWhen(true)
@@ -287,6 +364,29 @@ class MeshServiceNotifications(
return messageNotificationBuilder.build()
}
+ lateinit var alertNotificationBuilder: NotificationCompat.Builder
+ private fun createAlertNotification(
+ contactKey: String,
+ name: String,
+ alert: String
+ ): Notification {
+ if (!::alertNotificationBuilder.isInitialized) {
+ alertNotificationBuilder = commonBuilder(alertChannelId)
+ }
+ val person = Person.Builder().setName(name).build()
+ with(alertNotificationBuilder) {
+ setContentIntent(openMessageIntent(contactKey))
+ 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) {
diff --git a/app/src/main/res/raw/alert.mp3 b/app/src/main/res/raw/alert.mp3
new file mode 100644
index 000000000..d29768fd8
Binary files /dev/null and b/app/src/main/res/raw/alert.mp3 differ
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 6364a6809..51ac8501c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -148,6 +148,7 @@
message reception state
Message delivery status
Message notifications
+ Alert notifications
Protocol stress test
Firmware update required
The radio firmware is too old to talk to this application. For more information on this see our Firmware Installation guide.
@@ -319,6 +320,11 @@
Unknown Age
Copy
Alert Bell Character!
+ Channel Settings
+ Samsung Instructions
+ Enable Critical Alerts to bypass Do Not Disturb
+
Samsung users may need to add an exception in system settings before enabling it for the Alerts Channel. Visit Samsung Support for assistance..]]>
+ Critical Alert!
Favorite
Add \'%s\' as a favorite node?
Remove \'%s\' as a favorite node?