feat: add UnifiedPush as an alternative to FCM for push notifications

This commit is contained in:
Linus Rath
2026-09-24 18:17:43 +02:00
parent 6c82130267
commit ee04e95d4f
14 changed files with 1701 additions and 37 deletions
@@ -9,6 +9,7 @@ class BulwarkFcmPackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
listOf(
BulwarkFcmModule(reactContext),
BulwarkUnifiedPushModule(reactContext),
BulwarkClientCertModule(reactContext),
)
@@ -31,7 +31,7 @@ class BulwarkMessagingService : FirebaseMessagingService() {
// receives JMAP push directly, so the fcm:message event below is
// sufficient for it to refresh state.
if (!isAppInForeground()) {
startHeadlessTask(data)
startHeadlessTask(applicationContext, data)
}
val params = Arguments.createMap().apply {
@@ -42,26 +42,28 @@ class BulwarkMessagingService : FirebaseMessagingService() {
BulwarkFcmModule.emit("fcm:message", params)
}
private fun isAppInForeground(): Boolean {
val info = ActivityManager.RunningAppProcessInfo()
ActivityManager.getMyMemoryState(info)
return info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND ||
info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE
}
private fun startHeadlessTask(data: Map<String, String>) {
val intent = Intent(applicationContext, BulwarkPushTaskService::class.java)
val bundle = Bundle().apply {
for ((k, v) in data) putString(k, v)
}
intent.putExtras(bundle)
applicationContext.startService(intent)
HeadlessJsTaskService.acquireWakeLockNow(applicationContext)
}
companion object {
const val CHANNEL_ID = "bulwark_mail"
// Shared with BulwarkUnifiedPushService - a push arriving over either
// transport is dispatched the same way.
fun isAppInForeground(): Boolean {
val info = ActivityManager.RunningAppProcessInfo()
ActivityManager.getMyMemoryState(info)
return info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND ||
info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE
}
fun startHeadlessTask(context: Context, data: Map<String, String>) {
val intent = Intent(context, BulwarkPushTaskService::class.java)
val bundle = Bundle().apply {
for ((k, v) in data) putString(k, v)
}
intent.putExtras(bundle)
context.startService(intent)
HeadlessJsTaskService.acquireWakeLockNow(context)
}
fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -0,0 +1,110 @@
package com.anonymous.bulwarkmobile
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import org.unifiedpush.android.connector.UnifiedPush
// JS-facing surface for UnifiedPush distributor management. Events from the
// distributor (endpoint changes, incoming messages) arrive through
// BulwarkUnifiedPushService, which emits them on the shared device-event
// channel.
class BulwarkUnifiedPushModule(reactContext: ReactApplicationContext)
: ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = "BulwarkUnifiedPush"
@ReactMethod
fun getDistributors(promise: Promise) {
try {
val array = Arguments.createArray()
for (d in UnifiedPush.getDistributors(reactApplicationContext)) array.pushString(d)
promise.resolve(array)
} catch (err: Exception) {
promise.reject("up_distributors_failed", err)
}
}
@ReactMethod
fun getSavedDistributor(promise: Promise) {
try {
promise.resolve(UnifiedPush.getSavedDistributor(reactApplicationContext))
} catch (err: Exception) {
promise.reject("up_distributor_failed", err)
}
}
@ReactMethod
fun getAckDistributor(promise: Promise) {
try {
promise.resolve(UnifiedPush.getAckDistributor(reactApplicationContext))
} catch (err: Exception) {
promise.reject("up_distributor_failed", err)
}
}
@ReactMethod
fun saveDistributor(distributor: String, promise: Promise) {
try {
UnifiedPush.saveDistributor(reactApplicationContext, distributor)
promise.resolve(null)
} catch (err: Exception) {
promise.reject("up_save_distributor_failed", err)
}
}
// Asks the saved distributor for an endpoint. The endpoint itself arrives
// asynchronously via BulwarkUnifiedPushService.onNewEndpoint (event
// `up:newEndpoint`); failures via `up:registrationFailed`. `vapid` is the
// relay's VAPID public key - optional, some distributors use it to
// restrict who may push.
@ReactMethod
fun register(vapid: String?, promise: Promise) {
try {
UnifiedPush.register(
reactApplicationContext,
messageForDistributor = null,
vapid = vapid?.takeIf { it.isNotBlank() },
)
promise.resolve(null)
} catch (err: Exception) {
promise.reject("up_register_failed", err)
}
}
@ReactMethod
fun unregister(promise: Promise) {
try {
UnifiedPush.unregister(reactApplicationContext)
BulwarkUnifiedPushService.clearEndpoint(reactApplicationContext)
promise.resolve(null)
} catch (err: Exception) {
promise.reject("up_unregister_failed", err)
}
}
@ReactMethod
fun getEndpoint(promise: Promise) {
val stored = BulwarkUnifiedPushService.readEndpoint(reactApplicationContext)
if (stored == null) {
promise.resolve(null)
return
}
val (url, p256dh, auth) = stored
val map = Arguments.createMap().apply {
putString("url", url)
putString("p256dh", p256dh)
putString("auth", auth)
}
promise.resolve(map)
}
// NativeEventEmitter required no-ops.
@ReactMethod
fun addListener(eventName: String) {}
@ReactMethod
fun removeListeners(count: Int) {}
}
@@ -0,0 +1,100 @@
package com.anonymous.bulwarkmobile
import android.content.Context
import com.facebook.react.bridge.Arguments
import org.json.JSONArray
import org.json.JSONObject
import org.unifiedpush.android.connector.FailedReason
import org.unifiedpush.android.connector.PushService
import org.unifiedpush.android.connector.data.PushEndpoint
import org.unifiedpush.android.connector.data.PushMessage
// Receives UnifiedPush events from the user's distributor app (ntfy, ...).
// The relay sends the same content-blind ForwardPayload it sends over FCM -
// as encrypted Web Push when the connector provided RFC 8291 keys (the
// connector decrypts before this service sees it) - so an incoming message is
// dispatched exactly like an FCM data message: headless task when the app is
// backgrounded, `fcm:message` device event for the live JS instance.
class BulwarkUnifiedPushService : PushService() {
override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
saveEndpoint(this, endpoint)
val params = Arguments.createMap().apply {
putString("url", endpoint.url)
putString("p256dh", endpoint.pubKeySet?.pubKey)
putString("auth", endpoint.pubKeySet?.auth)
}
BulwarkFcmModule.emit("up:newEndpoint", params)
}
override fun onMessage(message: PushMessage, instance: String) {
BulwarkMessagingService.ensureChannel(this)
val data = parsePayload(message.content) ?: return
if (!BulwarkMessagingService.isAppInForeground()) {
BulwarkMessagingService.startHeadlessTask(applicationContext, data)
}
// Same event name and shape as the FCM path so the foreground JS
// handler (App.tsx addMessageListener) works transport-agnostically.
val params = Arguments.createMap().apply {
val dataMap = Arguments.createMap()
for ((k, v) in data) dataMap.putString(k, v)
putMap("data", dataMap)
}
BulwarkFcmModule.emit("fcm:message", params)
}
override fun onUnregistered(instance: String) {
clearEndpoint(this)
BulwarkFcmModule.emit("up:unregistered", Arguments.createMap())
}
override fun onRegistrationFailed(reason: FailedReason, instance: String) {
val params = Arguments.createMap().apply { putString("reason", reason.name) }
BulwarkFcmModule.emit("up:registrationFailed", params)
}
companion object {
private const val PREFS = "bulwark_unifiedpush"
private const val KEY_URL = "endpointUrl"
private const val KEY_P256DH = "endpointP256dh"
private const val KEY_AUTH = "endpointAuth"
// The endpoint arrives asynchronously (possibly while no JS instance
// is alive), so persist it for BulwarkUnifiedPushModule.getEndpoint.
fun saveEndpoint(context: Context, endpoint: PushEndpoint) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
.putString(KEY_URL, endpoint.url)
.putString(KEY_P256DH, endpoint.pubKeySet?.pubKey)
.putString(KEY_AUTH, endpoint.pubKeySet?.auth)
.apply()
}
fun readEndpoint(context: Context): Triple<String, String?, String?>? {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
val url = prefs.getString(KEY_URL, null) ?: return null
return Triple(url, prefs.getString(KEY_P256DH, null), prefs.getString(KEY_AUTH, null))
}
fun clearEndpoint(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
}
// The relay's ForwardPayload JSON, flattened to the string map the
// FCM data payload uses (repos/relay/src/fcm.ts) so the headless task
// and foreground handler can stay transport-blind.
fun parsePayload(content: ByteArray): Map<String, String>? = try {
val json = JSONObject(String(content, Charsets.UTF_8))
buildMap {
json.optString("kind").takeIf { it.isNotEmpty() }?.let { put("kind", it) }
json.optString("accountLabel").takeIf { it.isNotEmpty() }?.let { put("accountLabel", it) }
json.optString("accountId").takeIf { it.isNotEmpty() }?.let { put("accountId", it) }
put("emailIds", (json.optJSONArray("emailIds") ?: JSONArray()).toString())
put("changed", (json.optJSONObject("changed") ?: JSONObject()).toString())
}
} catch (_: Exception) {
null
}
}
}