Skip to content

Commit 09d1032

Browse files
jamesarichclaude
andcommitted
fix(notifications): address review feedback on threading, privacy, and cancellation
- ConversationShortcutPublisher observes on the IO dispatcher: the caller's scope is Main-bound (MeshService), and publishing does ShortcutManager IPC plus avatar bitmap rasterization on every recency-affecting DB change. - Drop the contactKey from the on-demand shortcut failure logs; node/channel identifiers are user-traceable (privacy-first convention). - ReplyReceiver rethrows CancellationException instead of treating it as a failed send, and uses safeCatching (which preserves cancellation) rather than runCatching for the notification calls. - Strengthen the DM refresh test to assert the resolved peer name on the published shortcut label, not just the absent conversation title. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fdf099a commit 09d1032

4 files changed

Lines changed: 44 additions & 8 deletions

File tree

core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/ConversationShortcutPublisherTest.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,15 @@ import dev.mokkery.MockMode
2424
import dev.mokkery.answering.returns
2525
import dev.mokkery.every
2626
import dev.mokkery.mock
27+
import kotlinx.coroutines.Dispatchers
2728
import kotlinx.coroutines.flow.MutableStateFlow
2829
import kotlinx.coroutines.flow.flowOf
2930
import kotlinx.coroutines.test.advanceUntilIdle
3031
import kotlinx.coroutines.test.runTest
3132
import org.junit.Before
3233
import org.junit.Test
3334
import org.junit.runner.RunWith
35+
import org.meshtastic.core.di.CoroutineDispatchers
3436
import org.meshtastic.core.model.DataPacket
3537
import org.meshtastic.core.model.Node
3638
import org.meshtastic.core.repository.NodeRepository
@@ -61,9 +63,15 @@ class ConversationShortcutPublisherTest {
6163
private val packetRepository: PacketRepository = mock(MockMode.autofill)
6264
private val nodeRepository: NodeRepository = mock(MockMode.autofill)
6365
private val radioConfigRepository: RadioConfigRepository = mock(MockMode.autofill)
66+
private val dispatchers =
67+
CoroutineDispatchers(
68+
io = Dispatchers.Unconfined,
69+
main = Dispatchers.Unconfined,
70+
default = Dispatchers.Unconfined,
71+
)
6472

6573
private val publisher =
66-
ConversationShortcutPublisher(context, nodeRepository, packetRepository, radioConfigRepository)
74+
ConversationShortcutPublisher(context, nodeRepository, packetRepository, radioConfigRepository, dispatchers)
6775

6876
@Before
6977
fun setUp() {

core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshNotificationManagerImplConversationTest.kt

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,14 @@ import dev.mokkery.every
2727
import dev.mokkery.everySuspend
2828
import dev.mokkery.matcher.any
2929
import dev.mokkery.mock
30+
import kotlinx.coroutines.Dispatchers
3031
import kotlinx.coroutines.flow.MutableStateFlow
3132
import kotlinx.coroutines.flow.flowOf
3233
import kotlinx.coroutines.test.runTest
3334
import org.junit.Before
3435
import org.junit.Test
3536
import org.junit.runner.RunWith
37+
import org.meshtastic.core.di.CoroutineDispatchers
3638
import org.meshtastic.core.model.ConnectionState
3739
import org.meshtastic.core.model.Message
3840
import org.meshtastic.core.model.MyNodeInfo
@@ -74,7 +76,17 @@ class MeshNotificationManagerImplConversationTest {
7476
nodeRepository = lazy { nodeRepository },
7577
conversationShortcutPublisher =
7678
lazy {
77-
ConversationShortcutPublisher(context, nodeRepository, packetRepository, radioConfigRepository)
79+
ConversationShortcutPublisher(
80+
context,
81+
nodeRepository,
82+
packetRepository,
83+
radioConfigRepository,
84+
CoroutineDispatchers(
85+
io = Dispatchers.Unconfined,
86+
main = Dispatchers.Unconfined,
87+
default = Dispatchers.Unconfined,
88+
),
89+
)
7890
},
7991
radioConfigRepository = lazy { radioConfigRepository },
8092
)
@@ -211,5 +223,11 @@ class MeshNotificationManagerImplConversationTest {
211223
val posted = activeByTag("message").single().notification
212224
// A DM is not a group conversation, so no conversation title is set.
213225
assertNull(posted.extras.getCharSequence(Notification.EXTRA_CONVERSATION_TITLE))
226+
// The on-demand shortcut published for the notification carries the peer's resolved name.
227+
val shortcut =
228+
context.getSystemService(android.content.pm.ShortcutManager::class.java)!!.dynamicShortcuts.single {
229+
it.id == "0!00000007"
230+
}
231+
assertEquals("Hawk Ridge", shortcut.shortLabel)
214232
}
215233
}

core/service/src/androidMain/kotlin/org/meshtastic/core/service/ConversationShortcutPublisher.kt

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
3434
import kotlinx.coroutines.flow.map
3535
import kotlinx.coroutines.launch
3636
import org.koin.core.annotation.Single
37+
import org.meshtastic.core.di.CoroutineDispatchers
3738
import org.meshtastic.core.model.Channel
3839
import org.meshtastic.core.model.DataPacket
3940
import org.meshtastic.core.model.NodeAddress
@@ -63,14 +64,17 @@ class ConversationShortcutPublisher(
6364
private val nodeRepository: NodeRepository,
6465
private val packetRepository: PacketRepository,
6566
private val radioConfigRepository: RadioConfigRepository,
67+
private val dispatchers: CoroutineDispatchers,
6668
) {
6769

6870
private var observeJob: Job? = null
6971

7072
fun startObserving(scope: CoroutineScope) {
7173
observeJob?.cancel()
7274
observeJob =
73-
scope.launch {
75+
// The caller's scope may be Main-bound (MeshService); shortcut publishing does ShortcutManager IPC and
76+
// rasterizes avatar bitmaps on every recency-affecting DB change, so keep it off the Main thread.
77+
scope.launch(dispatchers.io) {
7478
// Combine the message DB (recency + DM peers) with the channel config (names). Ranking both by
7579
// last-activity time lets launchers / Android Auto surface the *recently active* conversations first
7680
// (the shortcut display cap is small), instead of an arbitrary slice of stale channels.
@@ -252,9 +256,10 @@ class ConversationShortcutPublisher(
252256
try {
253257
ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)
254258
} catch (e: IllegalArgumentException) {
255-
Logger.e(tag = TAG, throwable = e) { "Failed to publish on-demand shortcut $contactKey" }
259+
// Don't log the contactKey: node/channel identifiers are user-traceable (privacy-first convention).
260+
Logger.e(tag = TAG, throwable = e) { "Failed to publish on-demand shortcut" }
256261
} catch (e: IllegalStateException) {
257-
Logger.e(tag = TAG, throwable = e) { "Failed to publish on-demand shortcut $contactKey" }
262+
Logger.e(tag = TAG, throwable = e) { "Failed to publish on-demand shortcut" }
258263
}
259264
}
260265

core/service/src/androidMain/kotlin/org/meshtastic/core/service/ReplyReceiver.kt

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,14 @@ import android.content.Context
2121
import android.content.Intent
2222
import androidx.core.app.RemoteInput
2323
import co.touchlab.kermit.Logger
24+
import kotlinx.coroutines.CancellationException
2425
import kotlinx.coroutines.CoroutineScope
2526
import kotlinx.coroutines.SupervisorJob
2627
import kotlinx.coroutines.launch
2728
import org.koin.core.component.KoinComponent
2829
import org.koin.core.component.inject
2930
import org.meshtastic.core.common.util.nowMillis
31+
import org.meshtastic.core.common.util.safeCatching
3032
import org.meshtastic.core.di.CoroutineDispatchers
3133
import org.meshtastic.core.repository.MeshNotificationManager
3234
import org.meshtastic.core.repository.PacketRepository
@@ -84,15 +86,18 @@ class ReplyReceiver :
8486
// Re-post the conversation silently with the sent reply appended — the MessagingStyle confirmation
8587
// flow. This resolves the RemoteInput spinner with visible feedback instead of the notification
8688
// vanishing. Fall back to dismissal so the spinner never hangs if the refresh itself fails.
87-
runCatching { meshServiceNotifications.refreshConversationAfterReply(contactKey) }
89+
safeCatching { meshServiceNotifications.refreshConversationAfterReply(contactKey) }
8890
.onFailure {
8991
Logger.e(tag = TAG, throwable = it) { "refresh after reply failed" }
90-
runCatching { meshServiceNotifications.cancelMessageNotification(contactKey) }
92+
safeCatching { meshServiceNotifications.cancelMessageNotification(contactKey) }
9193
}
94+
} catch (e: CancellationException) {
95+
// Preserve structured concurrency — never treat cancellation as a failed send.
96+
throw e
9297
} catch (e: Exception) {
9398
Logger.e(tag = TAG, throwable = e) { "reply send failed" }
9499
// The send failed; dismiss so the RemoteInput spinner resolves rather than hanging forever.
95-
runCatching { meshServiceNotifications.cancelMessageNotification(contactKey) }
100+
safeCatching { meshServiceNotifications.cancelMessageNotification(contactKey) }
96101
.onFailure { Logger.e(tag = TAG, throwable = it) { "cancel notification failed" } }
97102
} finally {
98103
pendingResult?.finish()

0 commit comments

Comments
 (0)