fix(service): route notification quick-replies through the send pipeline; drop dead parcelize plugin (#6196)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-07-10 10:24:31 -05:00
committed by GitHub
parent 57d11e3255
commit 1d91a9f22c
8 changed files with 134 additions and 21 deletions

View File

@@ -28,7 +28,6 @@ plugins {
alias(libs.plugins.meshtastic.android.application.compose)
alias(libs.plugins.meshtastic.kotlinx.serialization)
id("meshtastic.koin")
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.secrets)
alias(libs.plugins.androidx.baselineprofile)
id("meshtastic.aboutlibraries")

View File

@@ -40,6 +40,14 @@
-dontwarn org.bouncycastle.**
-dontwarn org.openjsse.**
# ---- BLE (Kable) -------------------------------------------------------------
# Kable annotates ScanResultAndroidAdvertisement with @Parcelize but doesn't
# ship the parcelize runtime in its POM; the app doesn't apply the parcelize
# plugin (no @Parcelize usage of our own), so the annotation class is absent.
# Annotation classes aren't needed at runtime — Kable's generated CREATOR
# works without it.
-dontwarn kotlinx.parcelize.Parcelize
# ---- AppSearch / AppFunctions generated document factories ------------------
# AppSearch's DocumentClassFactoryRegistry loads the generated
# `$$__AppSearch__*` factory classes by name and instantiates them via their

View File

@@ -28,7 +28,6 @@ plugins {
alias(libs.plugins.room) apply false
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.kotlin.parcelize) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.aboutlibraries) apply false
alias(libs.plugins.secrets) apply false

View File

@@ -19,7 +19,6 @@ plugins {
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.meshtastic.android.room)
alias(libs.plugins.meshtastic.kotlinx.serialization)
alias(libs.plugins.kotlin.parcelize)
id("meshtastic.koin")
}

View File

@@ -17,7 +17,6 @@
plugins {
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.meshtastic.kotlinx.serialization)
alias(libs.plugins.kotlin.parcelize)
id("meshtastic.koin")
}

View File

@@ -0,0 +1,119 @@
/*
* Copyright (c) 2026 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 org.meshtastic.core.service
import android.content.Intent
import android.os.Bundle
import androidx.core.app.RemoteInput
import androidx.test.core.app.ApplicationProvider
import dev.mokkery.MockMode
import dev.mokkery.answering.throws
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
import dev.mokkery.mock
import dev.mokkery.verify
import dev.mokkery.verify.VerifyMode
import dev.mokkery.verifySuspend
import kotlinx.coroutines.Dispatchers
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.usecase.SendMessageUseCase
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ReplyReceiverTest {
private val sendMessageUseCase: SendMessageUseCase = mock(MockMode.autofill)
private val notificationManager: MeshNotificationManager = mock(MockMode.autofill)
private val packetRepository: PacketRepository = mock(MockMode.autofill)
@Before
fun setUp() {
startKoin {
modules(
module {
single { sendMessageUseCase }
single { notificationManager }
single { packetRepository }
// Unconfined so the receiver's launched coroutine completes before onReceive returns
single {
CoroutineDispatchers(
io = Dispatchers.Unconfined,
main = Dispatchers.Unconfined,
default = Dispatchers.Unconfined,
)
}
},
)
}
}
@After
fun tearDown() {
stopKoin()
}
private fun replyIntent(contactKey: String, text: String): Intent {
val intent = Intent(ReplyReceiver.REPLY_ACTION).putExtra(ReplyReceiver.CONTACT_KEY, contactKey)
val results = Bundle().apply { putCharSequence(ReplyReceiver.KEY_TEXT_REPLY, text) }
RemoteInput.addResultsToIntent(
arrayOf(RemoteInput.Builder(ReplyReceiver.KEY_TEXT_REPLY).build()),
intent,
results,
)
return intent
}
@Test
fun `reply goes through SendMessageUseCase and marks conversation read`() {
val contactKey = "0!12345678"
ReplyReceiver().onReceive(ApplicationProvider.getApplicationContext(), replyIntent(contactKey, "hello back"))
verifySuspend { sendMessageUseCase.invoke("hello back", contactKey, null) }
verifySuspend { packetRepository.clearUnreadCount(contactKey, any()) }
verify { notificationManager.cancelMessageNotification(contactKey) }
}
@Test
fun `notification is cancelled even when the send fails`() {
val contactKey = "0!12345678"
everySuspend { sendMessageUseCase.invoke(any(), any(), any()) } throws RuntimeException("radio down")
ReplyReceiver().onReceive(ApplicationProvider.getApplicationContext(), replyIntent(contactKey, "hi"))
verifySuspend(mode = VerifyMode.exactly(0)) { packetRepository.clearUnreadCount(any(), any()) }
verify { notificationManager.cancelMessageNotification(contactKey) }
}
@Test
fun `missing RemoteInput results does not send`() {
ReplyReceiver().onReceive(ApplicationProvider.getApplicationContext(), Intent(ReplyReceiver.REPLY_ACTION))
verifySuspend(mode = VerifyMode.exactly(0)) { sendMessageUseCase.invoke(any(), any(), any()) }
}
}

View File

@@ -28,23 +28,21 @@ import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.ContactKey
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioController
import org.meshtastic.core.repository.usecase.SendMessageUseCase
/**
* 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.
* and the contact key from the intent, sends the message through [SendMessageUseCase] — so notification replies get the
* same pipeline as in-app sends (history save, durable queue, transforms) — and then cancels the notification.
*/
class ReplyReceiver :
BroadcastReceiver(),
KoinComponent {
private val radioController: RadioController by inject()
private val sendMessageUseCase: SendMessageUseCase by inject()
private val meshServiceNotifications: MeshNotificationManager by inject()
@@ -73,11 +71,11 @@ class ReplyReceiver :
val message = remoteInput.getCharSequence(KEY_TEXT_REPLY)?.toString().orEmpty()
Logger.i(tag = TAG) { "reply for contactKey=$contactKey len=${message.length}" }
val pendingResult = goAsync()
val pendingResult: PendingResult? = goAsync()
scope.launch {
try {
// Send first so the reply isn't lost; then dismiss. The cancel can't break the send.
sendMessage(message, contactKey)
sendMessageUseCase(message, contactKey)
// Replying implies the conversation has been read — mark it so, like the mark-as-read action.
// Android Auto keys notification dismissal off read state, not just cancel().
packetRepository.clearUnreadCount(contactKey, nowMillis)
@@ -87,15 +85,8 @@ class ReplyReceiver :
} finally {
runCatching { meshServiceNotifications.cancelMessageNotification(contactKey) }
.onFailure { Logger.e(tag = TAG, throwable = it) { "cancel notification failed" } }
pendingResult.finish()
pendingResult?.finish()
}
}
}
private suspend fun sendMessage(str: String, contactKey: String) {
// contactKey: unique contact key filter (channel)+(nodeId)
val parsedKey = ContactKey(contactKey)
val p = DataPacket(parsedKey.addressString, parsedKey.channel, str)
radioController.sendMessage(p)
}
}

View File

@@ -338,7 +338,6 @@ compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-m
koin-compiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" }
mokkery = { id = "dev.mokkery", version.ref = "mokkery" }