mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 16:05:39 +00:00
With device sync on, Android should hear about a change as soon as the
app does, not only at the next periodic sync. This adds what tells it:
- The app's own contact and calendar edits, StateChanges on the live
stream for `AddressBook`/`ContactCard` and `Calendar`/`CalendarEvent`,
and the app coming back to the foreground request a sync of that
account and authority. Requests within 5 s fold into one, and a
StateChange the engine caused itself (its own upload echoing back) is
skipped.
- While the app is closed, the account's push subscription carries
those types too. The FCM and UnifiedPush services hand every push to
the native router from f743ab0, which requests the syncs, and start
the mail push task only when a push has `EmailDelivery`; the task also
returns early for such pushes, so a contact change can never show the
"newest unread mail" notification.
- At launch, on foreground and when Android's accounts change, the app
checks Android's accounts against its own: an account removed in
Android Settings shows sync off and is not recreated, and one whose app
account is gone stops syncing and posts "Sign in again", keeping its
data.
The functions that turn sync on and off (`src/device-sync/app/lifecycle.ts`)
come with this; the settings that call them land in a later commit, so
nothing here runs before an account has device sync on.
Checked: the new store, lifecycle, trigger and push tests, `npm run
typecheck`, `npm test` and `npm run i18n:check` on this commit, and the
debug build.
105 lines
4.8 KiB
Kotlin
105 lines
4.8 KiB
Kotlin
package com.anonymous.bulwarkmobile
|
|
|
|
import android.content.Context
|
|
import com.anonymous.bulwarkmobile.sync.DeviceSyncPushRouter
|
|
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
|
|
|
|
// Same routing as FCM: contact and calendar changes request device
|
|
// syncs, and only mail starts the push task.
|
|
val mail = DeviceSyncPushRouter.route(applicationContext, data)
|
|
if (mail && !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
|
|
}
|
|
}
|
|
}
|