diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0f9e42f02..89215e377 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -73,6 +73,9 @@ + + + diff --git a/app/src/main/java/com/geeksville/mesh/MainActivity.kt b/app/src/main/java/com/geeksville/mesh/MainActivity.kt index 3b70b5c61..84e4fe59a 100644 --- a/app/src/main/java/com/geeksville/mesh/MainActivity.kt +++ b/app/src/main/java/com/geeksville/mesh/MainActivity.kt @@ -25,10 +25,12 @@ 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.method.LinkMovementMethod import android.view.Menu import android.view.MenuItem @@ -56,6 +58,7 @@ import com.geeksville.mesh.android.ServiceClient import com.geeksville.mesh.android.getBluetoothPermissions import com.geeksville.mesh.android.getNotificationPermissions import com.geeksville.mesh.android.hasBluetoothPermission +import com.geeksville.mesh.android.isNotificationPolicyAccessGranted import com.geeksville.mesh.android.hasNotificationPermission import com.geeksville.mesh.android.permissionMissing import com.geeksville.mesh.android.rationaleDialog @@ -437,6 +440,15 @@ class MainActivity : AppCompatActivity(), Logging { notificationPermissionsLauncher.launch(notificationPermissions) } } + if (!isNotificationPolicyAccessGranted() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + rationaleDialog( + title = R.string.dnd_required, + rationale = getString(R.string.why_dnd_required), + ) { + val intent = Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS) + startActivity(intent) + } + } } } diff --git a/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt b/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt index 473facb08..6aefb9ad7 100644 --- a/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt +++ b/app/src/main/java/com/geeksville/mesh/android/ContextServices.kt @@ -24,6 +24,7 @@ import android.bluetooth.BluetoothManager import android.content.Context import android.content.pm.PackageManager import android.location.LocationManager +import android.os.Build import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment @@ -167,12 +168,21 @@ fun Context.hasLocationPermission() = getLocationPermissions().isEmpty() */ fun Context.getNotificationPermissions(): Array { val perms = mutableListOf() - if (android.os.Build.VERSION.SDK_INT >= 33) { + if (Build.VERSION.SDK_INT >= 33) { perms.add(Manifest.permission.POST_NOTIFICATIONS) } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + perms.add(Manifest.permission.ACCESS_NOTIFICATION_POLICY) + } return getMissingPermissions(perms) } /** @return true if the user already has notification permission */ fun Context.hasNotificationPermission() = getNotificationPermissions().isEmpty() + +fun Context.isNotificationPolicyAccessGranted() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + notificationManager.isNotificationPolicyAccessGranted +} else { + false +} 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 f88f23d64..abbd0f1c0 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -271,6 +271,14 @@ class MeshService : Service(), Logging { startPacketQueue() } + private fun showAlertNotification(contactKey: String, dataPacket: DataPacket) { + serviceNotifications.showAlertNotification( + contactKey, + getSenderName(dataPacket), + dataPacket.text!! + ) + } + private fun updateMessageNotification(contactKey: String, dataPacket: DataPacket) { val message: String = when (dataPacket.dataType) { Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> dataPacket.text!! @@ -630,6 +638,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, ) @@ -666,7 +675,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) + } } } } @@ -704,6 +717,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 554ad0262..8cb9898f7 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt @@ -22,11 +22,17 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context +import android.content.Context.AUDIO_SERVICE import android.content.Intent import android.graphics.Color import android.media.AudioAttributes +import android.media.AudioManager +import android.media.MediaMetadataRetriever import android.media.RingtoneManager +import android.net.Uri import android.os.Build +import android.os.Handler +import android.os.Looper import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.app.Person @@ -36,6 +42,7 @@ import com.geeksville.mesh.TelemetryProtos.LocalStats import com.geeksville.mesh.android.notificationManager import com.geeksville.mesh.database.entity.NodeEntity import com.geeksville.mesh.util.formatUptime +import kotlin.math.ceil @Suppress("TooManyFunctions") class MeshServiceNotifications( @@ -43,9 +50,11 @@ class MeshServiceNotifications( ) { companion object { + private const val OVERRIDE_VOLUME = 8.8f 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,6 +62,57 @@ class MeshServiceNotifications( // We have two notification channels: one for general service status and another one for messages val notifyId = 101 + private fun overrideSilentModeAndConfigureCustomVolume(ringToneVolume: Float?) { + val audioManager = context.getSystemService(AUDIO_SERVICE) as AudioManager + var originalRingMode = audioManager.ringerMode + val originalNotificationVolume = + audioManager.getStreamVolume(AudioManager.STREAM_NOTIFICATION) + val maxNotificationVolume = + audioManager.getStreamMaxVolume(AudioManager.STREAM_NOTIFICATION) + + // When DND mode is enabled, we get ringerMode as silent even though actual ringer mode is Normal + val isDndModeEnabled = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + notificationManager.currentInterruptionFilter != NotificationManager.INTERRUPTION_FILTER_ALL + } else { + true + } + if (isDndModeEnabled && + originalRingMode == AudioManager.RINGER_MODE_SILENT && + originalNotificationVolume != 0 + ) { + originalRingMode = AudioManager.RINGER_MODE_NORMAL + } + + val newVolume = if (ringToneVolume != null) { + ceil(maxNotificationVolume * ringToneVolume).toInt() + } else { + originalNotificationVolume + } + + audioManager.ringerMode = AudioManager.RINGER_MODE_NORMAL + audioManager.setStreamVolume(AudioManager.STREAM_NOTIFICATION, newVolume, 0) + + // Resetting the original ring mode, volume and dnd mode + Handler(Looper.getMainLooper()).postDelayed( + { + audioManager.ringerMode = originalRingMode + audioManager.setStreamVolume( + AudioManager.STREAM_NOTIFICATION, + originalNotificationVolume, + 0 + ) + }, + getSoundFileDuration(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)) + ) + } + + private fun getSoundFileDuration(uri: Uri): Long { + val mmr = MediaMetadataRetriever() + mmr.setDataSource(context, uri) + val durationStr = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION) + return durationStr?.toLong() ?: 0 + } + @RequiresApi(Build.VERSION_CODES.O) private fun createNotificationChannel(): String { val channelId = "my_service" @@ -93,6 +153,31 @@ class MeshServiceNotifications( return channelId } + @RequiresApi(Build.VERSION_CODES.O) + private fun createAlertNotificationChannel(): String { + val channelId = "my_alerts" + val channelName = context.getString(R.string.meshtastic_alerts_notifications) + val channel = NotificationChannel( + channelId, + channelName, + NotificationManager.IMPORTANCE_HIGH + ).apply { + setBypassDnd(true) + 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() + ) + } + notificationManager.createNotificationChannel(channel) + return channelId + } + @RequiresApi(Build.VERSION_CODES.O) private fun createNewNodeNotificationChannel(): String { val channelId = "new_nodes" @@ -137,6 +222,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() @@ -179,6 +272,14 @@ class MeshServiceNotifications( createMessageNotification(contactKey, name, message) ) + fun showAlertNotification(contactKey: String, name: String, alert: String) { + overrideSilentModeAndConfigureCustomVolume(OVERRIDE_VOLUME) + notificationManager.notify( + contactKey.hashCode(), // show unique notifications, + createAlertNotification(contactKey, name, alert) + ) + } + fun showNewNodeSeenNotification(node: NodeEntity) { notificationManager.notify( node.num, // show unique notifications @@ -268,7 +369,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,12 +384,36 @@ 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) ) } 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_MAX + setCategory(Notification.CATEGORY_MESSAGE) + 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/values/strings.xml b/app/src/main/res/values/strings.xml index 63975f30e..4df3071cc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -141,6 +141,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, please go to the settings pane and choose \"Update Firmware\". For more information on this see our Firmware Installation guide. @@ -314,4 +315,6 @@ Unknown Age Copy Alert Bell Character! + Do Not Disturb Permission Required + Allow Modes access to \'Meshtastic\' app\"]]>