Skip to content

Commit a837cd9

Browse files
authored
Merge pull request #22 from SableClient/fix/ntfy-payload-delivery-2012
fix(android): handle Matrix push payloads across providers
2 parents 758156f + 31fe288 commit a837cd9

7 files changed

Lines changed: 362 additions & 56 deletions

File tree

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,3 +755,52 @@ The plugin does **not** install this file for you — it's a packaging/deploymen
755755
## License
756756

757757
[MIT](LICENSE)
758+
759+
### Android Matrix push integration (Sable v1 and v2)
760+
761+
Keep the mobile Rust notification calls asynchronous and await them. Calling the
762+
synchronous mobile bridge while Tauri holds its plugin-store lock can deadlock a
763+
WebView page-load callback. This includes `set_encrypted_content_allowed` and
764+
`take_push_diagnostics`, not only notification registration.
765+
766+
The Android renderer accepts a flat Matrix notification, an object under
767+
`notification`, or a JSON string under `notification`. Account identity may be on
768+
the envelope, the notification, or in `devices[].data.user_id` /
769+
`devices[].data.default_payload.user_id`. The latter is required for ntfy, which
770+
relays the Matrix gateway request unchanged. Conflicting account identities are
771+
rejected. Include the recipient in every pusher registration; the open UI account
772+
is not a substitute for a missing push recipient.
773+
774+
A notification with no room is a control/count update and is not displayed. Zero
775+
unread clears the corresponding room notification. Encrypted messages are posted
776+
immediately with a generic preview. Optional host-native decryption then updates
777+
the same notification silently, only while that notification is still current and
778+
the encrypted-content policy still allows it. Missing keys or a host without the
779+
optional JNI decryptor leave the generic notification visible. Host apps must set
780+
the encrypted-content policy to the conjunction of their general preview and
781+
encrypted-preview settings. Decryption never uses another account's registration.
782+
783+
Registration results need different server routes:
784+
785+
- `p256dh` and `auth`: use a WebPush-capable gateway (or a supporting homeserver).
786+
- HTTPS endpoint without those keys: use a Matrix UnifiedPush gateway, such as the
787+
endpoint provider's discovered gateway or a configured UnifiedPush gateway.
788+
- Bare FCM/APNs token: use the corresponding configured platform gateway/app ID.
789+
790+
Do not register a plain ntfy endpoint as an FCM token. Persist/re-register the
791+
pusher on startup and update it when the endpoint changes. Android force-stop
792+
prevents background delivery until the user opens the app again.
793+
794+
Run the renderer and integration regressions with `cd android && ./gradlew
795+
:testDebugUnitTest --tests app.tauri.notification.UnifiedPushNotifierTest` (after
796+
Tauri's Android bindings have been generated).
797+
798+
### iOS Matrix push integration
799+
800+
iOS requires an APNs gateway app ID in `pushNotificationDetails.iosPushAppID`.
801+
Android continues to use `nativePushAppID`. The signing profile must enable APNs
802+
for the bundle ID, and the gateway must use the matching APNs environment.
803+
804+
Closed-app delivery requires `aps.alert`. Background decryption requires a host
805+
notification service extension; the plugin's encrypted-content setting applies
806+
only to Android. Use generic APNs alerts when previews are disabled.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package app.tauri.notification
2+
3+
import org.json.JSONObject
4+
5+
internal object MatrixPushPayload {
6+
fun parse(raw: String): JSONObject? {
7+
val root = try { JSONObject(raw) } catch (_: Exception) { return null }
8+
val notification = when (val nested = root.opt("notification")) {
9+
null -> root
10+
is JSONObject -> nested
11+
is String -> try { JSONObject(nested) } catch (_: Exception) { return null }
12+
else -> return null
13+
}
14+
val recipients = mutableSetOf<String>()
15+
fun add(value: Any?) {
16+
if (value is String && value.isNotBlank()) recipients.add(value.trim())
17+
}
18+
add(root.opt("user_id"))
19+
add(notification.opt("user_id"))
20+
val devices = notification.optJSONArray("devices")
21+
for (index in 0 until (devices?.length() ?: 0)) {
22+
val data = devices?.optJSONObject(index)?.optJSONObject("data") ?: continue
23+
add(data.opt("user_id"))
24+
add(data.optJSONObject("default_payload")?.opt("user_id"))
25+
}
26+
if (recipients.size > 1) return null
27+
recipients.singleOrNull()?.let { notification.put("user_id", it) }
28+
return notification
29+
}
30+
}

android/src/main/java/app/tauri/notification/UnifiedPushNotifier.kt

Lines changed: 45 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import android.app.PendingIntent
66
import android.content.Context
77
import android.content.Intent
88
import android.os.Build
9+
import android.os.Bundle
10+
import java.util.UUID
911
import androidx.core.app.NotificationCompat
1012
import androidx.core.app.NotificationManagerCompat
1113
import androidx.core.app.Person
@@ -31,19 +33,45 @@ object UnifiedPushNotifier {
3133
}
3234

3335
fun showFromPush(context: Context, rawMessage: String) {
34-
val rootJson = try {
35-
JSONObject(rawMessage)
36-
} catch (e: Exception) {
37-
null
38-
} ?: return
36+
val notification = MatrixPushPayload.parse(rawMessage) ?: return
37+
val roomId = notification.optString("room_id")
38+
if (roomId.isEmpty()) return
39+
val userId = notification.optString("user_id")
40+
val manager = context.getSystemService(NotificationManager::class.java)
41+
val id = if (userId.isNotEmpty()) roomNotificationId(userId, roomId) else fallbackNotificationId(roomId)
42+
if (notification.optJSONObject("counts")?.optInt("unread", -1) == 0) {
43+
manager.cancel(id)
44+
return
45+
}
46+
val generation = UUID.randomUUID().toString()
47+
val encrypted = notification.optString("type") == "m.room.encrypted"
48+
post(context, notification, if (encrypted) "Encrypted message" else null, generation)
49+
if (!encrypted) return
3950

40-
// Also accept the payload nested as a JSON string, not just an object.
41-
val notification = rootJson.optJSONObject("notification")
42-
?: rootJson.optString("notification").takeIf { it.isNotEmpty() }?.let {
43-
try { JSONObject(it) } catch (e: Exception) { null }
44-
}
45-
?: return
51+
val state = UnifiedPushStateStore(context)
52+
if (!state.showEncryptedContent) {
53+
PushDiagnostics.record(context, PushOutcome.HIDDEN_BY_SETTING)
54+
return
55+
}
56+
if (userId.isEmpty() || userId != state.pushUserId) return
57+
val (body, outcome) = decryptedBody(context, notification)
58+
PushDiagnostics.record(context, outcome)
59+
if (body == null || !state.showEncryptedContent) return
60+
val stillCurrent = manager.activeNotifications.any {
61+
it.id == id && it.tag == null && it.notification.extras.getString(GENERATION_KEY) == generation
62+
}
63+
if (stillCurrent) post(context, notification, body, generation, silent = true)
64+
}
4665

66+
private const val GENERATION_KEY = "sable.push.generation"
67+
68+
private fun post(
69+
context: Context,
70+
notification: JSONObject,
71+
preview: String?,
72+
generation: String,
73+
silent: Boolean = false,
74+
) {
4775
val roomId = notification.optString("room_id")
4876
val eventId = notification.optString("event_id")
4977
val sender = notification.optString("sender_display_name")
@@ -55,13 +83,11 @@ object UnifiedPushNotifier {
5583
} else {
5684
roomName.ifEmpty { sender.ifEmpty { "New message" } }
5785
}
58-
val text = if (isInvite) null else messageText(context, notification)
86+
val text = if (isInvite) null else preview ?: messageText(context, notification)
5987
val body = if (isInvite) buildInviteBody(sender, roomName) else buildBody(sender, text.orEmpty())
6088
val channelId = if (isInvite) INVITES_CHANNEL_ID else MESSAGES_CHANNEL_ID
6189

62-
val userId = rootJson.optString("user_id").ifEmpty {
63-
notification.optString("user_id")
64-
}
90+
val userId = notification.optString("user_id")
6591

6692
ensureChannels(context)
6793

@@ -95,6 +121,7 @@ object UnifiedPushNotifier {
95121

96122
val builder = NotificationCompat.Builder(context, channelId)
97123
.setSmallIcon(iconId)
124+
.addExtras(Bundle().apply { putString(GENERATION_KEY, generation) })
98125
.setContentTitle(title)
99126
.setContentText(body)
100127
.setAutoCancel(true)
@@ -105,6 +132,8 @@ object UnifiedPushNotifier {
105132
PendingIntent.getActivity(context, notifId, intent, flags)
106133
)
107134

135+
if (silent) builder.setSilent(true)
136+
108137
// Same style as the warm path, so JS enrichment updates it in place.
109138
if (isInvite) {
110139
builder.setStyle(NotificationCompat.BigTextStyle().bigText(body))
@@ -230,14 +259,7 @@ object UnifiedPushNotifier {
230259
?: "New message"
231260
}
232261

233-
if (!UnifiedPushStateStore(context).showEncryptedContent) {
234-
PushDiagnostics.record(context, PushOutcome.HIDDEN_BY_SETTING)
235-
return "Encrypted message"
236-
}
237-
238-
val (body, outcome) = decryptedBody(context, notification)
239-
PushDiagnostics.record(context, outcome)
240-
return body ?: "Encrypted message"
262+
return "Encrypted message"
241263
}
242264

243265
private fun decryptedBody(

android/src/test/java/app/tauri/notification/UnifiedPushNotifierTest.kt

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import org.robolectric.RobolectricTestRunner
1515
import org.robolectric.RuntimeEnvironment
1616
import org.robolectric.Shadows.shadowOf
1717
import org.robolectric.annotation.Config
18+
import io.mockk.every
19+
import io.mockk.mockkObject
20+
import io.mockk.unmockkObject
1821

1922
@RunWith(RobolectricTestRunner::class)
2023
@Config(sdk = [34])
@@ -69,6 +72,162 @@ class UnifiedPushNotifierTest {
6972
private fun canonicalId(roomId: String, userId: String = "@alice:example.org") =
7073
UnifiedPushNotifier.roomNotificationId(userId, roomId)
7174

75+
@Test
76+
fun showFromPush_ntfyPayloadUsesRegisteredAccountForIdentityAndTap() {
77+
val payload = JSONObject(pushPayload("!ntfy:example.org", "\$ntfy", userId = null))
78+
payload.getJSONObject("notification").put("devices", org.json.JSONArray().put(
79+
JSONObject().put("pushkey", "https://ntfy.sh/up123?up=1").put("data",
80+
JSONObject().put("default_payload", JSONObject().put("user_id", "@alice:example.org")))
81+
))
82+
UnifiedPushNotifier.showFromPush(context, payload.toString())
83+
val posted = shadowNotificationManager().getNotification(null, canonicalId("!ntfy:example.org"))
84+
assertNotNull("ntfy delivery must use the same identity as warm enrichment", posted)
85+
val source = shadowOf(posted!!.contentIntent).savedIntent.getStringExtra(NOTIFICATION_OBJ_INTENT_KEY)!!
86+
assertTrue(source.contains("@alice:example.org"))
87+
}
88+
89+
@Test
90+
fun showFromPush_routesSupportedGatewayPayloadsToTheSameAccountAndRoom() {
91+
for (wrapper in listOf("flat", "object", "string")) {
92+
for (recipient in listOf("notification", "envelope", "device", "default_payload")) {
93+
val notification = JSONObject()
94+
.put("room_id", "!contract:example.org")
95+
.put("event_id", "\$contract")
96+
.put("type", "m.room.message")
97+
.put("content", JSONObject().put("body", "contract message"))
98+
val envelope = when (wrapper) {
99+
"flat" -> notification
100+
else -> JSONObject()
101+
}
102+
when (recipient) {
103+
"notification" -> notification.put("user_id", "@alice:example.org")
104+
"envelope" -> envelope.put("user_id", "@alice:example.org")
105+
else -> {
106+
val data = JSONObject()
107+
if (recipient == "device") data.put("user_id", "@alice:example.org")
108+
else data.put("default_payload", JSONObject().put("user_id", "@alice:example.org"))
109+
notification.put("devices", org.json.JSONArray().put(JSONObject().put("data", data)))
110+
}
111+
}
112+
if (wrapper == "object") envelope.put("notification", notification)
113+
if (wrapper == "string") envelope.put("notification", notification.toString())
114+
notificationManager.cancelAll()
115+
UnifiedPushNotifier.showFromPush(context, envelope.toString())
116+
val posted = shadowNotificationManager().getNotification(null, canonicalId("!contract:example.org"))
117+
assertNotNull("$wrapper / $recipient", posted)
118+
assertTrue(posted!!.extras.getString(Notification.EXTRA_TEXT)!!.contains("contract message"))
119+
val source = shadowOf(posted.contentIntent).savedIntent.getStringExtra(NOTIFICATION_OBJ_INTENT_KEY)!!
120+
assertTrue(source.contains("@alice:example.org"))
121+
}
122+
}
123+
}
124+
125+
@Test
126+
fun showFromPush_acceptsFlatMinimalPush() {
127+
val payload = JSONObject().put("room_id", "!flat:example.org")
128+
.put("event_id", "\$flat").put("user_id", "@alice:example.org").toString()
129+
UnifiedPushNotifier.showFromPush(context, payload)
130+
assertNotNull(shadowNotificationManager().getNotification(null, canonicalId("!flat:example.org")))
131+
}
132+
133+
@Test
134+
fun showFromPush_postsBaselineBeforeNativeDecryption() {
135+
val state = UnifiedPushStateStore(context)
136+
state.pushUserId = "@alice:example.org"
137+
state.pushDeviceId = "DEVICE"
138+
state.showEncryptedContent = true
139+
var baselineWasVisible = false
140+
mockkObject(PushPayloadDecryptor)
141+
try {
142+
every { PushPayloadDecryptor.decrypt(any(), any(), any(), any(), any()) } answers {
143+
baselineWasVisible = shadowNotificationManager().getNotification(null, canonicalId("!enc:example.org")) != null
144+
PushDecryptResult.Success("""{"content":{"body":"decrypted"}}""")
145+
}
146+
val payload = JSONObject(pushPayload("!enc:example.org", "\$enc"))
147+
payload.getJSONObject("notification").put("type", "m.room.encrypted")
148+
UnifiedPushNotifier.showFromPush(context, payload.toString())
149+
assertTrue("a slow native decrypt must not delay notification delivery", baselineWasVisible)
150+
val posted = shadowNotificationManager().getNotification(null, canonicalId("!enc:example.org"))!!
151+
assertTrue(posted.extras.getString(Notification.EXTRA_TEXT)!!.contains("decrypted"))
152+
} finally {
153+
unmockkObject(PushPayloadDecryptor)
154+
}
155+
}
156+
157+
@Test
158+
fun showFromPush_honorsPrivacyChangesDuringDecryption() {
159+
val state = UnifiedPushStateStore(context)
160+
state.pushUserId = "@alice:example.org"
161+
state.pushDeviceId = "DEVICE"
162+
state.showEncryptedContent = true
163+
mockkObject(PushPayloadDecryptor)
164+
try {
165+
every { PushPayloadDecryptor.decrypt(any(), any(), any(), any(), any()) } answers {
166+
state.showEncryptedContent = false
167+
PushDecryptResult.Success("""{"content":{"body":"private plaintext"}}""")
168+
}
169+
val payload = JSONObject(pushPayload("!enc:example.org", "\$enc"))
170+
payload.getJSONObject("notification").put("type", "m.room.encrypted")
171+
UnifiedPushNotifier.showFromPush(context, payload.toString())
172+
val posted = shadowNotificationManager().getNotification(null, canonicalId("!enc:example.org"))!!
173+
assertTrue(posted.extras.getString(Notification.EXTRA_TEXT)!!.contains("Encrypted message"))
174+
} finally {
175+
unmockkObject(PushPayloadDecryptor)
176+
}
177+
}
178+
179+
@Test
180+
fun showFromPush_acceptsV2DeviceAccountMetadata() {
181+
val payload = JSONObject(pushPayload("!v2:example.org", "\$v2", userId = null))
182+
payload.getJSONObject("notification").put("devices", org.json.JSONArray().put(
183+
JSONObject().put("data", JSONObject().put("user_id", "@alice:example.org"))
184+
))
185+
UnifiedPushNotifier.showFromPush(context, payload.toString())
186+
assertNotNull(shadowNotificationManager().getNotification(null, canonicalId("!v2:example.org")))
187+
}
188+
189+
@Test
190+
fun showFromPush_rejectsConflictingRecipientsAndControlMessages() {
191+
val payload = JSONObject(pushPayload("!conflict:example.org", "\$conflict"))
192+
payload.getJSONObject("notification").put("user_id", "@other:example.org")
193+
UnifiedPushNotifier.showFromPush(context, payload.toString())
194+
UnifiedPushNotifier.showFromPush(context, """{"notification":{"counts":{"unread":5}}}""")
195+
UnifiedPushNotifier.showFromPush(context, """{"app_id":"app","ack_token":"token"}""")
196+
assertTrue(shadowNotificationManager().allNotifications.isEmpty())
197+
}
198+
199+
@Test
200+
fun showFromPush_clearsReadRoomsWithoutPostingAnAlert() {
201+
UnifiedPushNotifier.showFromPush(context, pushPayload("!read:example.org", "\$read"))
202+
UnifiedPushNotifier.showFromPush(context, """{"notification":{"user_id":"@alice:example.org","room_id":"!read:example.org","counts":{"unread":0}}}""")
203+
assertTrue(shadowNotificationManager().allNotifications.isEmpty())
204+
}
205+
206+
@Test
207+
fun showFromPush_lateDecryptionDoesNotResurrectDismissedOrSupersededAlerts() {
208+
val state = UnifiedPushStateStore(context)
209+
state.pushUserId = "@alice:example.org"
210+
state.pushDeviceId = "DEVICE"
211+
state.showEncryptedContent = true
212+
mockkObject(PushPayloadDecryptor)
213+
try {
214+
for (supersede in listOf(false, true)) {
215+
every { PushPayloadDecryptor.decrypt(any(), any(), any(), any(), any()) } answers {
216+
notificationManager.cancel(canonicalId("!enc:example.org"))
217+
if (supersede) UnifiedPushNotifier.showFromPush(context,
218+
pushPayload("!enc:example.org", "\$new", "newer message"))
219+
PushDecryptResult.Success("""{"content":{"body":"stale plaintext"}}""")
220+
}
221+
val payload = JSONObject(pushPayload("!enc:example.org", "\$old"))
222+
payload.getJSONObject("notification").put("type", "m.room.encrypted")
223+
UnifiedPushNotifier.showFromPush(context, payload.toString())
224+
val posted = shadowNotificationManager().getNotification(null, canonicalId("!enc:example.org"))
225+
if (supersede) assertTrue(posted!!.extras.getString(Notification.EXTRA_TEXT)!!.contains("newer message"))
226+
else assertNull(posted)
227+
}
228+
} finally { unmockkObject(PushPayloadDecryptor) }
229+
}
230+
72231
@Test
73232
fun roomNotificationId_matchesDeployedJsAbsHashSemantics() {
74233
// Fixed vectors with expectations computed from Sable's JS

0 commit comments

Comments
 (0)