diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bf2db4f91..46348d5f7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,20 +1,20 @@ + ~ 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 . + --> --> + 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 376b40bdb..e319b72dd 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshServiceNotifications.kt @@ -32,6 +32,7 @@ import android.os.Build import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.core.app.Person +import androidx.core.app.RemoteInput import androidx.core.net.toUri import com.geeksville.mesh.MainActivity import com.geeksville.mesh.R @@ -39,6 +40,7 @@ import com.geeksville.mesh.TelemetryProtos.LocalStats import com.geeksville.mesh.android.notificationManager import com.geeksville.mesh.database.entity.NodeEntity 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 @Suppress("TooManyFunctions") @@ -314,6 +316,10 @@ class MeshServiceNotifications( ) } + fun cancelMessageNotification(contactKey: String) { + notificationManager.cancel(contactKey.hashCode()) + } + fun updateMessageNotification(contactKey: String, name: String, message: String) = notificationManager.notify( contactKey.hashCode(), // show unique notifications, @@ -354,6 +360,13 @@ class MeshServiceNotifications( ) } + private fun createMessageReplyIntent(contactKey: String): Intent { + return Intent(context, ReplyReceiver::class.java).apply { + action = ReplyReceiver.REPLY_ACTION + putExtra(ReplyReceiver.CONTACT_KEY, contactKey) + } + } + private fun createOpenMessageIntent(contactKey: String): PendingIntent { val deepLink = "$DEEP_LINK_BASE_URI/messages/$contactKey" val deepLinkIntent = Intent( @@ -365,7 +378,7 @@ class MeshServiceNotifications( val deepLinkPendingIntent: PendingIntent = TaskStackBuilder.create(context).run { addNextIntentWithParentStack(deepLinkIntent) - getPendingIntent(0, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + getPendingIntent(0, PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) } return deepLinkPendingIntent @@ -376,6 +389,7 @@ class MeshServiceNotifications( contentIntent: PendingIntent? = null ): NotificationCompat.Builder { val builder = NotificationCompat.Builder(context, channel) + .setDefaults(NotificationCompat.DEFAULT_ALL) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setContentIntent(contentIntent ?: openAppIntent) @@ -425,17 +439,37 @@ class MeshServiceNotifications( return serviceNotificationBuilder.build() } - lateinit var messageNotificationBuilder: NotificationCompat.Builder private fun createMessageNotification( contactKey: String, name: String, message: String ): Notification { - if (!::messageNotificationBuilder.isInitialized) { - messageNotificationBuilder = - commonBuilder(messageChannelId, createOpenMessageIntent(contactKey)) - } + val messageNotificationBuilder: NotificationCompat.Builder = + commonBuilder(messageChannelId, 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() + } + + // Build a PendingIntent for the reply action to trigger. + val replyPendingIntent: PendingIntent = + PendingIntent.getBroadcast( + context, + contactKey.hashCode(), + createMessageReplyIntent(contactKey), + 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) @@ -444,6 +478,7 @@ class MeshServiceNotifications( NotificationCompat.MessagingStyle(person) .addMessage(message, System.currentTimeMillis(), person) ) + addAction(action) setWhen(System.currentTimeMillis()) setShowWhen(true) } @@ -457,11 +492,11 @@ class MeshServiceNotifications( alert: String ): Notification { if (!::alertNotificationBuilder.isInitialized) { - alertNotificationBuilder = commonBuilder(alertChannelId) + alertNotificationBuilder = + commonBuilder(alertChannelId, createOpenMessageIntent(contactKey)) } val person = Person.Builder().setName(name).build() with(alertNotificationBuilder) { - setContentIntent(createOpenMessageIntent(contactKey)) priority = NotificationCompat.PRIORITY_HIGH setCategory(Notification.CATEGORY_ALARM) setAutoCancel(true) diff --git a/app/src/main/java/com/geeksville/mesh/service/ReplyReceiver.kt b/app/src/main/java/com/geeksville/mesh/service/ReplyReceiver.kt new file mode 100644 index 000000000..25e887007 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/service/ReplyReceiver.kt @@ -0,0 +1,64 @@ +/* + * 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 . + */ + +package com.geeksville.mesh.service + +import android.content.BroadcastReceiver +import androidx.core.app.RemoteInput +import com.geeksville.mesh.DataPacket +import dagger.hilt.android.AndroidEntryPoint +import jakarta.inject.Inject + +/** + * A [BroadcastReceiver] that handles inline replies from notifications. + * + * This receiver is triggered when a user replies to a message directly from a notification. + * It extracts the reply text and the contact key from the intent, sends the message + * using the [ServiceRepository], and then cancels the original notification. + */ +@AndroidEntryPoint +class ReplyReceiver : BroadcastReceiver() { + @Inject + lateinit var serviceRepository: ServiceRepository + + companion object { + const val REPLY_ACTION = "com.geeksville.mesh.REPLY_ACTION" + const val CONTACT_KEY = "contactKey" + const val KEY_TEXT_REPLY = "key_text_reply" + } + + private fun sendMessage(str: String, contactKey: String = "0${DataPacket.ID_BROADCAST}") { + // contactKey: unique contact key filter (channel)+(nodeId) + val channel = contactKey[0].digitToIntOrNull() + val dest = if (channel != null) contactKey.substring(1) else contactKey + val p = DataPacket(dest, channel ?: 0, str) + serviceRepository.meshService?.send(p) + } + + override fun onReceive(context: android.content.Context, intent: android.content.Intent) { + val remoteInput = RemoteInput.getResultsFromIntent(intent) + + if (remoteInput != null) { + val contactKey = intent.getStringExtra(CONTACT_KEY) ?: "" + val message = remoteInput.getCharSequence( + KEY_TEXT_REPLY + )?.toString() ?: "" + sendMessage(message, contactKey) + MeshServiceNotifications(context).cancelMessageNotification(contactKey) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bd1e3f6f3..58da37a5b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -660,4 +660,5 @@ Map Contacts Nodes + Reply