feat: Allow direct message replies from notifications (#1994)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
pull/1997/head
James Rich 2025-05-31 20:48:24 -05:00 zatwierdzone przez GitHub
rodzic 6915249121
commit 4d6eb3dfe9
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
4 zmienionych plików z 124 dodań i 23 usunięć

Wyświetl plik

@ -1,20 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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 <https://www.gnu.org/licenses/>.
-->
~ 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 <https://www.gnu.org/licenses/>.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
@ -223,6 +223,7 @@
android:path="com.geeksville.mesh" /> -->
</intent-filter>
</receiver>
<receiver android:name="com.geeksville.mesh.service.ReplyReceiver"/>
<activity
android:name=".AppIntroduction"/>

Wyświetl plik

@ -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)

Wyświetl plik

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
}

Wyświetl plik

@ -660,4 +660,5 @@
<string name="map">Map</string>
<string name="contacts">Contacts</string>
<string name="nodes">Nodes</string>
<string name="reply">Reply</string>
</resources>