mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
feat: add UnifiedPush as an alternative to FCM for push notifications
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user