mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-10 13:25:20 -04:00
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:
@@ -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")
|
||||
|
||||
8
androidApp/proguard-rules.pro
vendored
8
androidApp/proguard-rules.pro
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user