Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,17 @@
</intent-filter>
</service>

<!-- UnifiedPush connector receiver -->
<receiver
<!-- UnifiedPush connector push service. The connector library's
internal MessagingReceiverImpl receives distributor broadcasts
and forwards them to this service, so no BroadcastReceiver is
declared for the connector actions. -->
<service
android:name="app.tauri.notification.UnifiedPushReceiver"
android:exported="true">
android:exported="false">
<intent-filter>
<action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT" />
<action android:name="org.unifiedpush.android.connector.UNREGISTERED" />
<action android:name="org.unifiedpush.android.connector.MESSAGE" />
<action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED" />
<action android:name="org.unifiedpush.android.connector.PUSH_EVENT" />
</intent-filter>
</receiver>
</service>
</application>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
Expand Down
23 changes: 23 additions & 0 deletions android/src/main/java/app/tauri/notification/NotificationPlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ class SetActionListenerActiveArgs {
var active: Boolean = false
}

@InvokeArg
class SetPushMessageListenerActiveArgs {
var active: Boolean = false
}

@InvokeArg
class DistributorArgs {
var distributor: String? = null
Expand Down Expand Up @@ -135,6 +140,11 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) {
private var hasActionListener = false
private val pendingNotificationActions = ArrayDeque<JSObject>()

// Push-message listener readiness: UnifiedPushReceiver always posts the
// native notification itself; the JS "push-message" event is emitted only
// once a listener has attached.
private var hasPushMessageListener = false

// onNewIntent can fire before load() during a cold start triggered
// by a notification tap (Android delivers the launch intent via
// both onCreate's activity.intent AND onNewIntent in certain launch
Expand Down Expand Up @@ -749,6 +759,12 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) {

fun onUnifiedPushMessage(content: String, instance: String) {
if (instance != unifiedPushState.activeInstance || unifiedPushState.activeProvider != "unifiedpush") return
if (!hasPushMessageListener) {
// UnifiedPushReceiver already posted the native notification; without a
// JS push-message listener attached yet, the event would be lost, so
// drop it and let the native post stand.
return
}
val data = JSObject()
data.put("message", content)
data.put("transport", "unifiedpush")
Expand Down Expand Up @@ -930,4 +946,11 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) {

invoke.resolve()
}

@Command
fun setPushMessageListenerActive(invoke: Invoke) {
val args = invoke.parseArgs(SetPushMessageListenerActiveArgs::class.java)
hasPushMessageListener = args.active
invoke.resolve()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.RemoteInput
import app.tauri.Logger
import com.fasterxml.jackson.databind.ObjectMapper
import org.json.JSONObject
import kotlin.math.abs

object UnifiedPushNotifier {
private const val CHANNEL_ID = "messages"
Expand Down Expand Up @@ -49,7 +51,21 @@ object UnifiedPushNotifier {
.getIdentifier("notification_icon", "drawable", context.packageName)
.takeIf { it != 0 } ?: android.R.drawable.ic_dialog_info

val notifId = sableNotifId(userId, roomId)
// Notification identity must match the warm path so the JS side can
// enrich or clear this entry: untagged Android key (null, id) with
// id = Math.abs(hashCode(userId + '\u0000' + roomId)). Without a user
// id in the payload the warm identity cannot be reproduced; fall
// back to the room/event key (stable same-room updates, but no warm
// clear/enrich match).
val notifId = if (userId.isNotEmpty() && roomId.isNotEmpty()) {
roomNotificationId(userId, roomId)
} else {
Logger.warn(
Logger.tags(TAG),
"Push payload has no user_id; cold notification will not match warm identity"
)
fallbackNotificationId(roomId.ifEmpty { eventId })
}

val intent = buildPushIntent(context, notifId, roomId, eventId, userId)

Expand All @@ -65,6 +81,7 @@ object UnifiedPushNotifier {
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setGroup(GROUP_KEY)
.setContentIntent(
Expand Down Expand Up @@ -105,15 +122,38 @@ object UnifiedPushNotifier {
}
}

private fun sableNotifId(userId: String, roomId: String): Int {
val key = "$userId\u0000$roomId"
var hash = 0
for (element in key) {
hash = 31 * hash + element.code
}
return Math.abs(hash)
/**
* Stable, nonnegative notification id matching the warm-path (deployed
* Sable JS) identity for a room:
* `Math.abs(hashCode(userId + '\u0000' + roomId))`. The JS hash is a 32-bit
* wrap-around hash over UTF-16 code units, exactly what
* [String.hashCode] computes, and JS `Math.abs` corresponds to
* [kotlin.math.abs] here. [Int.MIN_VALUE] has no positive counterpart
* (the JS result 2^31 cannot cross the bridge as an Int), so it is
* mapped safely to 0.
*/
internal fun roomNotificationId(userId: String, roomId: String): Int {
val hash = userId + '\u0000' + roomId
return hash.hashCode().let { if (it == Int.MIN_VALUE) 0 else abs(it) }
}

/**
* Stable, nonnegative id for a room-or-event key. Used only when the
* push payload carries no user id; this identity deliberately differs
* from the warm-path one.
*/
internal fun fallbackNotificationId(roomOrEventKey: String): Int =
roomOrEventKey.hashCode() and Int.MAX_VALUE

/**
* Builds an intent carrying the push payload so that
* [NotificationPlugin.onIntent] can extract it via
* [TauriNotificationManager.handleNotificationActionPerformed] and
* [NotificationPlugin.extractLocalNotificationData].
*
* Mirrors the structure set by [TauriNotificationManager.buildIntent] in the
* warm path (JS-triggered sendNotification).
*/
private fun buildPushIntent(
context: Context,
notifId: Int,
Expand Down
39 changes: 21 additions & 18 deletions android/src/main/java/app/tauri/notification/UnifiedPushReceiver.kt
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
package app.tauri.notification

import android.content.Context
import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.MessagingReceiver
import org.unifiedpush.android.connector.UnifiedPush
import org.unifiedpush.android.connector.PushService
import org.unifiedpush.android.connector.data.PushEndpoint
import org.unifiedpush.android.connector.data.PushMessage
import org.unifiedpush.android.connector.keys.KeyManager

class UnifiedPushReceiver : MessagingReceiver() {

override fun getKeyManager(context: Context): KeyManager {
return CachedKeyManager.getInstance(context)
}

override fun onNewEndpoint(context: Context, endpoint: PushEndpoint, instance: String) {
/**
* UnifiedPush entry point. Declared in the manifest as a non-exported
* [PushService] with an intent-filter for [PushService.ACTION_PUSH_EVENT];
* the connector library's own MessagingReceiverImpl receives the distributor
* broadcasts and forwards them to this service over a bound connection.
* Do NOT declare a BroadcastReceiver for the connector actions
* (NEW_ENDPOINT/MESSAGE/UNREGISTERED/REGISTRATION_FAILED/TEMP_UNAVAILABLE):
* it would shadow the library's MessagingReceiverImpl.
*/
class UnifiedPushReceiver : PushService() {
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
NotificationPlugin.instance?.onUnifiedPushNewEndpoint(
endpoint.url,
endpoint.pubKeySet?.pubKey,
Expand All @@ -23,30 +24,32 @@ class UnifiedPushReceiver : MessagingReceiver() {
)
}

override fun onRegistrationFailed(context: Context, reason: FailedReason, instance: String) {
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
NotificationPlugin.instance?.onUnifiedPushRegistrationFailed(reason.name, instance)
}

override fun onUnregistered(context: Context, instance: String) {
override fun onUnregistered(instance: String) {
NotificationPlugin.instance?.onUnifiedPushUnregistered(instance)
}

override fun onTempUnavailable(context: Context, instance: String) {
override fun onTempUnavailable(instance: String) {
NotificationPlugin.instance?.onUnifiedPushTemporaryUnavailable(instance)
}

override fun onMessage(context: Context, message: PushMessage, instance: String) {
override fun onMessage(message: PushMessage, instance: String) {
val content = String(message.content, Charsets.UTF_8)
val state = UnifiedPushStateStore(context)
val state = UnifiedPushStateStore(this)
if (instance != state.activeInstance || state.activeProvider != "unifiedpush") return
// Always show the native notification immediately from the push payload.
// This eliminates the JS round-trip delay on the warm path (app alive in
// background). JS still receives the push-message event for in-app badge
// updates and notification enrichment (inbox grouping, fetched content
// for event_id_only payloads). When JS calls sendNotification() with the
// same notification ID, Android UPDATES the existing notification rather
// than showing a duplicate.
UnifiedPushNotifier.showFromPush(context, content)
// than showing a duplicate. If no JS push-message listener is attached,
// NotificationPlugin.onUnifiedPushMessage drops the event and the native
// post stands alone.
UnifiedPushNotifier.showFromPush(this, content)
NotificationPlugin.instance?.onUnifiedPushMessage(content, instance)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package app.tauri.notification

import android.app.Notification
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.ResolveInfo
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.annotation.Config

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class UnifiedPushNotifierTest {

private lateinit var context: Context
private lateinit var notificationManager: NotificationManager

@Before
fun setup() {
context = RuntimeEnvironment.getApplication()
notificationManager = context.getSystemService(NotificationManager::class.java)

// UnifiedPushNotifier builds its tap intent via
// packageManager.getLaunchIntentForPackage(); register a fake
// launcher activity so the lookup resolves in tests.
val launchIntent = Intent(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER)
.setPackage(context.packageName)
shadowOf(context.packageManager).addResolveInfoForIntent(
launchIntent,
ResolveInfo().apply {
activityInfo = ActivityInfo().apply {
packageName = context.packageName
name = "TestLauncherActivity"
}
}
)
}

private fun shadowNotificationManager() = shadowOf(notificationManager)

private fun pushPayload(
roomId: String,
eventId: String,
body: String = "hello",
userId: String? = "@alice:example.org"
): String {
val notification = JSONObject()
.put("room_id", roomId)
.put("event_id", eventId)
.put("room_name", "Room 1")
.put("sender_display_name", "Alice")
.put("type", "m.room.message")
.put("content", JSONObject().put("body", body))
return JSONObject()
.put("notification", notification)
.apply { if (userId != null) put("user_id", userId) }
.toString()
}

private fun canonicalId(roomId: String, userId: String = "@alice:example.org") =
UnifiedPushNotifier.roomNotificationId(userId, roomId)

@Test
fun roomNotificationId_matchesDeployedJsAbsHashSemantics() {
// Fixed vectors with expectations computed from Sable's JS
// Math.abs(hashCode(userId + NUL + roomId)); they pin the key order,
// the NUL separator, and the 32-bit wrap-around hash exactly.
assertEquals(238601196, canonicalId("!r1:example.org")) // positive hash
assertEquals(1475650254, canonicalId("!room-7:example.org")) // hash -1475650254
}

@Test
fun roomNotificationId_mapsIntMinValueHashToZero() {
// roomId = UTF-16 units 00D9 001B 000C 0009 001E: the key hashes to
// exactly Int.MIN_VALUE under 32-bit wrap-around, where deployed JS
// Math.abs would yield 2^31 — a value that cannot cross the Tauri
// bridge as an Int, so the id must be mapped safely to 0.
val roomId = String(charArrayOf(0x00D9.toChar(), 0x001B.toChar(), 0x000C.toChar(), 0x0009.toChar(), 0x001E.toChar()))
assertEquals(Int.MIN_VALUE, "AAA${0.toChar()}$roomId".hashCode())
assertEquals(0, UnifiedPushNotifier.roomNotificationId("AAA", roomId))
}

@Test
fun fallbackNotificationId_fixedVectors() {
assertEquals(461444550, UnifiedPushNotifier.fallbackNotificationId("!r1:example.org"))
assertEquals(708055431, UnifiedPushNotifier.fallbackNotificationId("!room-2:example.org"))
assertEquals(0, UnifiedPushNotifier.fallbackNotificationId(""))
}

@Test
fun showFromPush_postsUntaggedNotificationWithCanonicalIdWithExpectedFlags() {
UnifiedPushNotifier.showFromPush(context, pushPayload("!r1:example.org", "\$e1"))

val id = canonicalId("!r1:example.org")
val posted = shadowNotificationManager().getNotification(null, id)
assertNotNull(posted)
// A tagged lookup for the same id must find nothing: warm
// enrichment/clear uses the untagged key (null, id).
assertNull(shadowNotificationManager().getNotification("!r1:example.org", id))
assertTrue(posted!!.flags and Notification.FLAG_ONLY_ALERT_ONCE != 0)
assertTrue(posted.flags and Notification.FLAG_AUTO_CANCEL != 0)
}

@Test
fun showFromPush_sameRoomUpdatesInPlace_andTapCarriesLatestEvent() {
UnifiedPushNotifier.showFromPush(context, pushPayload("!r1:example.org", "\$e1"))
UnifiedPushNotifier.showFromPush(context, pushPayload("!r1:example.org", "\$e2", "second"))

assertEquals(1, shadowNotificationManager().allNotifications.size)

val posted = shadowNotificationManager().getNotification(null, canonicalId("!r1:example.org"))!!
val savedIntent = shadowOf(posted.contentIntent).savedIntent
val sourceJson = savedIntent.getStringExtra(NOTIFICATION_OBJ_INTENT_KEY)!!
assertTrue(sourceJson.contains("\$e2"))
assertFalse(sourceJson.contains("\$e1"))
assertTrue(sourceJson.contains("!r1:example.org"))
}

@Test
fun showFromPush_differentRoomsPostSeparatelyUntagged() {
UnifiedPushNotifier.showFromPush(context, pushPayload("!r1:example.org", "\$e1"))
UnifiedPushNotifier.showFromPush(context, pushPayload("!r2:example.org", "\$e2"))

val shadow = shadowNotificationManager()
assertNotNull(shadow.getNotification(null, canonicalId("!r1:example.org")))
assertNotNull(shadow.getNotification(null, canonicalId("!r2:example.org")))
assertEquals(2, shadow.allNotifications.size)
}

@Test
fun showFromPush_withoutUserId_fallsBackToRoomKeyIdentity() {
UnifiedPushNotifier.showFromPush(
context,
pushPayload("!r3:example.org", "\$e9", userId = null)
)

val shadow = shadowNotificationManager()
assertNotNull(shadow.getNotification(null, UnifiedPushNotifier.fallbackNotificationId("!r3:example.org")))
// The fallback deliberately does NOT match the warm-path identity.
assertNull(shadow.getNotification(null, canonicalId("!r3:example.org")))
assertEquals(1, shadow.allNotifications.size)
}

@Test
fun showFromPush_ignoresMalformedPayloads() {
UnifiedPushNotifier.showFromPush(context, "not json at all")
UnifiedPushNotifier.showFromPush(context, """{"foo": "bar"}""")

assertTrue(shadowNotificationManager().allNotifications.isEmpty())
}
}
Loading