From 1d91a9f22ce609b5215aaaeb835f80bd40217198 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:24:31 -0500 Subject: [PATCH] fix(service): route notification quick-replies through the send pipeline; drop dead parcelize plugin (#6196) Co-authored-by: Claude Fable 5 --- androidApp/build.gradle.kts | 1 - androidApp/proguard-rules.pro | 8 ++ build.gradle.kts | 1 - core/database/build.gradle.kts | 1 - core/datastore/build.gradle.kts | 1 - .../core/service/ReplyReceiverTest.kt | 119 ++++++++++++++++++ .../meshtastic/core/service/ReplyReceiver.kt | 23 ++-- gradle/libs.versions.toml | 1 - 8 files changed, 134 insertions(+), 21 deletions(-) create mode 100644 core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReplyReceiverTest.kt diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 86261a4a8..f3580a045 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -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") diff --git a/androidApp/proguard-rules.pro b/androidApp/proguard-rules.pro index 527527451..fa33e4312 100644 --- a/androidApp/proguard-rules.pro +++ b/androidApp/proguard-rules.pro @@ -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 diff --git a/build.gradle.kts b/build.gradle.kts index ef70f005e..ac83326fb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index 4dd710a32..fb68bf3e4 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -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") } diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index 7657f4ba6..067caa9a0 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -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") } diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReplyReceiverTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReplyReceiverTest.kt new file mode 100644 index 000000000..d222eb0f3 --- /dev/null +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ReplyReceiverTest.kt @@ -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 . + */ +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()) } + } +} diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt index c03e568d3..3c6ed70f8 100644 --- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt +++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt @@ -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) - } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f37453303..21a9510d0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }