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