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?