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
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@ import android.content.Context
import org.unifiedpush.android.connector.keys.DefaultKeyManager
import org.unifiedpush.android.connector.keys.KeyManager
import org.unifiedpush.android.connector.data.PublicKeySet
import java.util.concurrent.ConcurrentHashMap

/**
* Wraps DefaultKeyManager with in-memory caching to avoid repeated
* AndroidKeyStore access for getPublicKeySet/exists calls.
*/
class CachedKeyManager private constructor(context: Context) : KeyManager {
private val delegate = DefaultKeyManager(context)
private val pubkeyCache = mutableMapOf<String, PublicKeySet>()
private val existsCache = mutableMapOf<String, Boolean>()
private val pubkeyCache = ConcurrentHashMap<String, PublicKeySet>()
private val existsCache = ConcurrentHashMap<String, Boolean>()

override fun decrypt(instance: String, sealed: ByteArray): ByteArray? {
return delegate.decrypt(instance, sealed)
Expand Down
77 changes: 62 additions & 15 deletions android/src/main/java/app/tauri/notification/NotificationPlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,25 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) {
return
}
val distributor = if (provider == "fcm") null else UnifiedPush.getSavedDistributor(activity)

// Reuse the current registration instead of re-registering.
if (provider != "fcm" &&
pendingPushRegistration == null &&
unifiedPushState.activeProvider == "unifiedpush" &&
distributor != null &&
distributor == unifiedPushState.distributor &&
requestedVapid == unifiedPushState.vapid &&
unifiedPushState.endpoint != null
) {
val cached = JSObject()
cached.put("deviceToken", unifiedPushState.endpoint)
cached.put("instance", UnifiedPushStateStore.INSTANCE)
unifiedPushState.p256dh?.let { cached.put("p256dh", it) }
unifiedPushState.auth?.let { cached.put("auth", it) }
invoke.resolve(cached)
return
}

pendingPushRegistration = PushRegistration(
requestedVapid,
provider,
Expand Down Expand Up @@ -508,10 +527,15 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) {
private fun proceedPushRegistration() {
val registration = pendingPushRegistration ?: return
val webPushVapid = registration.vapid
if (registration.provider != "fcm" && registration.distributor != null) {
// Re-read: the saved distributor may be gone, in which case register() sends
// no broadcast and we'd wait out the timeout. Fall through to selection.
val savedDistributor =
if (registration.provider == "fcm") null else UnifiedPush.getSavedDistributor(activity)
registration.distributor = savedDistributor
if (registration.provider != "fcm" && savedDistributor != null) {
registration.phase = PushRegistrationPhase.UNIFIED_PUSH
try {
UnifiedPush.register(activity, registration.instance!!, vapid = webPushVapid)
UnifiedPush.register(activity, registration.instance!!, vapid = webPushVapid, keyManager = CachedKeyManager.getInstance(activity))
} catch (error: Exception) {
finishPushRegistrationError(error.message ?: "UnifiedPush registration failed")
}
Expand All @@ -530,7 +554,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) {
try {
registration.distributor = UnifiedPush.getSavedDistributor(activity)
registration.phase = PushRegistrationPhase.UNIFIED_PUSH
UnifiedPush.register(activity, registration.instance!!, vapid = webPushVapid)
UnifiedPush.register(activity, registration.instance!!, vapid = webPushVapid, keyManager = CachedKeyManager.getInstance(activity))
} catch (error: Exception) {
finishPushRegistrationError(error.message ?: "UnifiedPush registration failed")
}
Expand All @@ -544,7 +568,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) {
if (registration.provider != "fcm" && UnifiedPush.getSavedDistributor(activity) != null) {
registration.phase = PushRegistrationPhase.UNIFIED_PUSH
try {
UnifiedPush.register(activity, registration.instance!!)
UnifiedPush.register(activity, registration.instance!!, keyManager = CachedKeyManager.getInstance(activity))
} catch (error: Exception) {
finishPushRegistrationError(error.message ?: "UnifiedPush registration failed")
}
Expand Down Expand Up @@ -593,11 +617,18 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) {
return
}

FirebaseMessaging.getInstance().deleteToken().addOnCompleteListener { task ->
if (!task.isSuccessful) {
invoke.reject("Failed to delete FCM token: ${task.exception?.message}")
return@addOnCompleteListener
try {
FirebaseMessaging.getInstance().deleteToken().addOnCompleteListener { task ->
if (!task.isSuccessful) {
invoke.reject("Failed to delete FCM token: ${task.exception?.message}")
return@addOnCompleteListener
}
fcmToken = null
if (unifiedPushState.activeProvider == "fcm") unifiedPushState.activeProvider = null
invoke.resolve()
}
} catch (error: Exception) {
// No default FirebaseApp (embedded-FCM/VAPID, no google-services.json): nothing to delete.
fcmToken = null
if (unifiedPushState.activeProvider == "fcm") unifiedPushState.activeProvider = null
invoke.resolve()
Expand Down Expand Up @@ -637,16 +668,17 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) {
return
}
if (pendingPushRegistration != null) {
invoke.reject("Cannot change distributor while push registration is in progress")
return
// Cancel the in-flight registration so the switch isn't blocked.
finishPushRegistrationError("Superseded by distributor change")
}
val distributorChanged = UnifiedPush.getSavedDistributor(activity) != distributor
if (distributorChanged) {
try { retireUnifiedPush(unifiedPushState.activeInstance ?: UnifiedPushStateStore.INSTANCE) } catch (error: Exception) {
invoke.reject(error.message ?: "Failed to retire UnifiedPush registration")
return
}
// saveDistributor already replaces the previous primary. Unregistering
// here would also wipe it, since unregister() drops every distributor
// once the last instance is removed.
unifiedPushGeneration++
if (unifiedPushState.activeProvider == "unifiedpush") unifiedPushState.activeProvider = null
unifiedPushState.clearRegistration()
}
UnifiedPush.saveDistributor(activity, distributor)
if (distributorChanged) unifiedPushState.ensureExplicitInstance()
Expand All @@ -662,11 +694,26 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) {
val registration = pendingPushRegistration
if (registration?.phase != PushRegistrationPhase.UNIFIED_PUSH ||
registration.generation != unifiedPushGeneration || instance != registration.instance) {
// Endpoint rotated outside a registration: keep the cache fresh.
if (pendingPushRegistration == null &&
unifiedPushState.activeProvider == "unifiedpush" &&
instance == (unifiedPushState.activeInstance ?: UnifiedPushStateStore.INSTANCE)
) {
unifiedPushState.endpoint = endpoint
unifiedPushState.p256dh = p256dh
unifiedPushState.auth = auth
unifiedPushState.distributor = UnifiedPush.getSavedDistributor(activity)
triggerUnifiedPushToken(endpoint, p256dh, auth, if (p256dh == null) "direct" else "webpush")
}
return
}
unifiedPushState.setUnifiedPushActive()
unifiedPushState.endpoint = endpoint
unifiedPushState.activeInstance = registration.instance
unifiedPushState.p256dh = p256dh
unifiedPushState.auth = auth
unifiedPushState.distributor = registration.distributor ?: UnifiedPush.getSavedDistributor(activity)
unifiedPushState.vapid = registration.vapid
val result = JSObject()
result.put("deviceToken", endpoint)
result.put("instance", UnifiedPushStateStore.INSTANCE)
Expand Down Expand Up @@ -782,7 +829,7 @@ class NotificationPlugin(private val activity: Activity): Plugin(activity) {

private fun retireUnifiedPush(instance: String?) {
unifiedPushGeneration++
try { UnifiedPush.unregister(activity, instance ?: UnifiedPushStateStore.INSTANCE) } catch (_: Exception) {
try { UnifiedPush.unregister(activity, instance ?: UnifiedPushStateStore.INSTANCE, CachedKeyManager.getInstance(activity)) } catch (_: Exception) {
}
val activeInstance = unifiedPushState.activeInstance
val retiresActiveInstance = activeInstance == instance ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class TauriFirebaseMessagingService : FirebaseMessagingService() {
message.from?.let { pushData["from"] = it }
pushData["sentTime"] = message.sentTime

if (message.data.isNotEmpty()) {
if (message.data.isNotEmpty() && UnifiedPushStateStore(this).activeProvider == "fcm") {
val dataJson = JSONObject(message.data as Map<String, Any>)
UnifiedPushNotifier.showFromPush(this, dataJson.toString())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,8 @@ class TauriNotificationManager(
Intent(context, activity.javaClass)
} else {
val packageName = context.packageName
context.packageManager.getLaunchIntentForPackage(packageName)!!
context.packageManager.getLaunchIntentForPackage(packageName)
?: Intent(Intent.ACTION_MAIN).setPackage(packageName)
}
intent.action = Intent.ACTION_MAIN
intent.addCategory(Intent.CATEGORY_LAUNCHER)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ object UnifiedPushNotifier {
null
} ?: return

val notification = rootJson.optJSONObject("notification") ?: return
// Also accept the payload nested as a JSON string, not just an object.
val notification = rootJson.optJSONObject("notification")
?: rootJson.optString("notification").takeIf { it.isNotEmpty() }?.let {
try { JSONObject(it) } catch (e: Exception) { null }
}
?: return

val roomId = notification.optString("room_id")
val eventId = notification.optString("event_id")
Expand Down Expand Up @@ -117,8 +122,8 @@ object UnifiedPushNotifier {
userId: String,
action: String = DEFAULT_PRESS_ACTION
): Intent {
val intent = context.packageManager
.getLaunchIntentForPackage(context.packageName)!!
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
?: Intent(Intent.ACTION_MAIN).setPackage(context.packageName)
intent.action = Intent.ACTION_MAIN
intent.addCategory(Intent.CATEGORY_LAUNCHER)
intent.flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,33 @@ internal class UnifiedPushStateStore(private val context: Context) {
var endpoint: String?
get() = prefs.getString("up-endpoint", null)
set(value) = prefs.edit().putString("up-endpoint", value).apply()
var p256dh: String?
get() = prefs.getString("up-p256dh", null)
set(value) = prefs.edit().putString("up-p256dh", value).apply()
var auth: String?
get() = prefs.getString("up-auth", null)
set(value) = prefs.edit().putString("up-auth", value).apply()
var distributor: String?
get() = prefs.getString("up-distributor", null)
set(value) = prefs.edit().putString("up-distributor", value).apply()
var vapid: String?
get() = prefs.getString("up-vapid", null)
set(value) = prefs.edit().putString("up-vapid", value).apply()

fun clearRegistration() {
prefs.edit().remove("push-instance").remove("up-endpoint").apply()
prefs.edit()
.remove("push-instance")
.remove("up-endpoint")
.remove("up-p256dh")
.remove("up-auth")
.remove("up-distributor")
.remove("up-vapid")
.apply()
}
fun instanceForRegistration(): String {
val current = activeInstance
if (current != null && current != INSTANCE) {
try { UnifiedPush.unregister(context, current) } catch (_: Exception) {}
try { UnifiedPush.unregister(context, current, CachedKeyManager.getInstance(context)) } catch (_: Exception) {}
activeInstance = INSTANCE
}
return INSTANCE
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package app.tauri.notification

import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment

@RunWith(RobolectricTestRunner::class)
class UnifiedPushStateStoreTest {

private fun newStore() = UnifiedPushStateStore(RuntimeEnvironment.getApplication())

@Test
fun persistsRegistrationAcrossInstances() {
val store = newStore()
store.activeProvider = "unifiedpush"
store.endpoint = "https://ntfy.sh/upAbc?up=1"
store.p256dh = "pubkey"
store.auth = "authsecret"
store.distributor = "io.heckel.ntfy"
store.vapid = "vapidkey"

val reopened = newStore()
assertEquals("unifiedpush", reopened.activeProvider)
assertEquals("https://ntfy.sh/upAbc?up=1", reopened.endpoint)
assertEquals("pubkey", reopened.p256dh)
assertEquals("authsecret", reopened.auth)
assertEquals("io.heckel.ntfy", reopened.distributor)
assertEquals("vapidkey", reopened.vapid)
}

@Test
fun clearRegistrationDropsKeysButKeepsProvider() {
val store = newStore()
store.activeProvider = "unifiedpush"
store.endpoint = "https://ntfy.sh/upAbc?up=1"
store.p256dh = "pubkey"
store.auth = "authsecret"
store.distributor = "io.heckel.ntfy"
store.vapid = "vapidkey"

store.clearRegistration()

assertNull(store.endpoint)
assertNull(store.p256dh)
assertNull(store.auth)
assertNull(store.distributor)
assertNull(store.vapid)
assertEquals("unifiedpush", store.activeProvider)
}

@Test
fun activeProviderIsNullWhenUnset() {
assertNull(newStore().activeProvider)
}
}
Loading