aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app/src/main/kotlin
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-09 12:08:31 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-09 12:08:31 +0200
commit6832df6177ad973ad0e1b4f0a49d7a6da06c6e04 (patch)
treed9040ce0da5d82400d1b973344615ca1c6b67b3c /packages/meshbay-android/app/src/main/kotlin
parent2860f1de75af1d44d35292ecbf79c68f02409d19 (diff)
downloadmeshbay-6832df6177ad973ad0e1b4f0a49d7a6da06c6e04.tar.gz
feat: notifications on Android while closed, with nothing to install
The phone fetches what is new every fifteen minutes with a poll secret (POST /v1/push/poll) that reads notification lines and nothing else. When a UnifiedPush distributor is already installed, the hub also pushes at once, encrypted to the phone (RFC 8291); losing the distributor falls back to fetching. The hub now honours "disable all notifications" itself: create_notification creates nothing for that account, as it already did for a muted group, so neither switch lets anything reach a phone. The interface used to be the only reader of the account-wide switch. Push endpoints are member-supplied URLs: a send refuses non-public addresses, connects to the address it checked, and follows no redirect. Android build untested here (no SDK on this machine). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-android/app/src/main/kotlin')
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt25
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt3
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Notifier.kt91
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PollJob.kt65
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Poller.kt29
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushChannels.kt76
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushReceiver.kt41
-rw-r--r--packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushState.kt126
8 files changed, 455 insertions, 1 deletions
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
index 0fa63d6..dad8462 100644
--- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/MainActivity.kt
@@ -32,6 +32,9 @@ import org.meshbay.client.cast.CastChannels
import org.meshbay.client.cast.CastService
import org.meshbay.client.hub.HubClient
import org.meshbay.client.keys.SecretStore
+import org.meshbay.client.notify.Notifier
+import org.meshbay.client.notify.PushChannels
+import org.meshbay.client.notify.PushState
import org.meshbay.client.save.SaveSinks
import org.meshbay.client.shell.Pickers
import org.meshbay.client.shell.ShellWebView
@@ -62,6 +65,7 @@ class MainActivity : Activity() {
private var playing = false
private var fullscreen: View? = null
private var fullscreenCallback: WebChromeClient.CustomViewCallback? = null
+ private var pendingLink: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -89,13 +93,28 @@ class MainActivity : Activity() {
tell = { m -> runOnUiThread { android.widget.Toast.makeText(this, m, android.widget.Toast.LENGTH_LONG).show() } })
channels = Channels(hub, onHubChanged = { runOnUiThread { reloadForHub() } },
hasCatalogue = { code -> hasAsset("ui/locales/$code.js") }, keys = keys, saves = saves,
- cast = cast, onPlayback = { on -> runOnUiThread { playing = on; keepAlive() } })
+ cast = cast, onPlayback = { on -> runOnUiThread { playing = on; keepAlive() } },
+ push = PushChannels(this, PushState(getSharedPreferences(PushState.PREFS, Context.MODE_PRIVATE)),
+ channelNames = { mapOf(
+ Notifier.CHANNEL_CHAT to text.get("push.channel_chat", channels.locale),
+ Notifier.CHANNEL_OTHER to text.get("push.channel_other", channels.locale)) }))
WebViewCompat.addWebMessageListener(web, Bridge.PORT, setOf(UiAssets.ORIGIN), Bridge(channels))
cast.control.warmUp()
installShim()
+ // Opened from a notification: to what it was about, once the page is up.
+ pendingLink = Notifier.linkOf(intent)
web.loadUrl(UiAssets.START)
}
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ Notifier.linkOf(intent)?.let { if (::web.isInitialized) goTo(it) }
+ }
+
+ /** A route inside the page (`#/…`), checked by Notifier — never a URL to load. */
+ private fun goTo(link: String) = web.evaluateJavascript("location.hash = ${JSONObject.quote(link)};", null)
+
private fun configure(web: WebView) {
WebView.setWebContentsDebuggingEnabled(BuildConfig.DEBUG)
web.settings.apply {
@@ -116,6 +135,10 @@ class MainActivity : Activity() {
.addPathHandler(UiAssets.PREFIX, UiAssets(this))
.build()
web.webViewClient = object : WebViewClientCompat() {
+ override fun onPageFinished(view: WebView, url: String) {
+ pendingLink?.let { pendingLink = null; goTo(it) }
+ }
+
override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
val url = request.url
if (url.host == UiAssets.HOST) return loader.shouldInterceptRequest(url) ?: refused()
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt
index f7094a0..5f202ba 100644
--- a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/bridge/Channels.kt
@@ -3,6 +3,7 @@ package org.meshbay.client.bridge
import org.json.JSONArray
import org.meshbay.client.cast.CastChannels
import org.meshbay.client.hub.HubClient
+import org.meshbay.client.notify.PushChannels
import org.meshbay.client.save.BinaryFrame
import org.meshbay.client.save.SaveSinks
import java.net.Inet4Address
@@ -26,6 +27,7 @@ class Channels(
private val saves: SaveSinks? = null,
private val cast: CastChannels? = null,
private val onPlayback: (Boolean) -> Unit = {},
+ private val push: PushChannels? = null,
) {
@Volatile var locale = "en"
private set
@@ -53,6 +55,7 @@ class Channels(
else -> when {
keys != null && keys.handles(channel) -> keys.call(channel, args)
cast != null && cast.handles(channel) -> cast.call(channel, args)
+ push != null && push.handles(channel) -> push.call(channel, args)
else -> throw Refused("Refused: no such channel")
}
}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Notifier.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Notifier.kt
new file mode 100644
index 0000000..7e6fce6
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Notifier.kt
@@ -0,0 +1,91 @@
+package org.meshbay.client.notify
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import org.json.JSONObject
+import org.meshbay.client.MainActivity
+import org.meshbay.client.R
+
+/**
+ * A notification from the hub, pushed or fetched, drawn by the system.
+ *
+ * The text is the hub's own line ("… posted in …") — the hub never holds a
+ * message, so neither does a push. A conversation is one entry per group,
+ * replaced as it goes on, as it is one row on the hub.
+ */
+object Notifier {
+ const val CHANNEL_CHAT = "chat"
+ const val CHANNEL_OTHER = "account"
+ const val EXTRA_LINK = "org.meshbay.client.LINK"
+ private val LINK = Regex("^#/[A-Za-z0-9/_-]{0,200}$")
+
+ data class Shown(val channel: String, val tag: String, val title: String, val link: String?,
+ val id: Long, val createdAt: String)
+
+ /**
+ * What to draw for a payload, or null for one that is not ours to draw.
+ * It was decrypted with this phone's key or fetched from the signed-in hub
+ * with this phone's secret; it is still checked like any input.
+ */
+ fun parse(content: ByteArray): Shown? {
+ val o = try { JSONObject(String(content, Charsets.UTF_8)) } catch (e: Exception) { return null }
+ if (o.optInt("v", 0) != 1) return null
+ val kind = o.optString("kind", "").take(32)
+ val title = o.optString("title", "").take(256).ifBlank { return null }
+ val group = if (o.isNull("group_id")) "" else o.optString("group_id", "").take(64)
+ val link = (if (o.isNull("link")) null else o.optString("link", null))?.takeIf { LINK.matches(it) }
+ val chat = kind == "chat_message" && group.isNotEmpty()
+ val id = o.optLong("id", 0)
+ val createdAt = o.optString("created_at", "").take(40)
+ val tag = if (chat) "chat:$group" else "n:$id"
+ return Shown(if (chat) CHANNEL_CHAT else CHANNEL_OTHER, tag, title, link, id, createdAt)
+ }
+
+ /** Draw it unless it was already drawn — pushed, then fetched, is one line. */
+ fun deliver(context: Context, state: PushState, shown: Shown) {
+ if (state.firstSight(shown.id, shown.createdAt)) show(context, shown)
+ }
+
+ /**
+ * The two channels, named in the person's language. Called with names when
+ * the page turns push on; from the receiver with none, only to make sure a
+ * channel exists, so a localized name is never overwritten by the fallback.
+ */
+ fun ensureChannels(context: Context, names: Map<String, String>? = null) {
+ val nm = context.getSystemService(NotificationManager::class.java)
+ for ((id, fallback, importance) in listOf(
+ Triple(CHANNEL_CHAT, "Messages", NotificationManager.IMPORTANCE_DEFAULT),
+ Triple(CHANNEL_OTHER, "Invitations and account", NotificationManager.IMPORTANCE_DEFAULT),
+ )) {
+ if (names == null && nm.getNotificationChannel(id) != null) continue
+ nm.createNotificationChannel(NotificationChannel(id, names?.get(id) ?: fallback, importance))
+ }
+ }
+
+ fun show(context: Context, shown: Shown) {
+ ensureChannels(context)
+ val open = Intent(context, MainActivity::class.java)
+ .setAction(Intent.ACTION_VIEW)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ shown.link?.let { open.putExtra(EXTRA_LINK, it) }
+ val pending = PendingIntent.getActivity(context, shown.tag.hashCode(), open,
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
+ val n = Notification.Builder(context, shown.channel)
+ .setSmallIcon(R.drawable.ic_notify)
+ .setContentTitle("MeshBay")
+ .setContentText(shown.title)
+ .setStyle(Notification.BigTextStyle().bigText(shown.title))
+ .setContentIntent(pending)
+ .setAutoCancel(true)
+ .setCategory(if (shown.channel == CHANNEL_CHAT) Notification.CATEGORY_MESSAGE else Notification.CATEGORY_SOCIAL)
+ .build()
+ context.getSystemService(NotificationManager::class.java).notify(shown.tag, 1, n)
+ }
+
+ /** A link from a notification the shell itself drew, checked again on the way in. */
+ fun linkOf(intent: Intent?): String? = intent?.getStringExtra(EXTRA_LINK)?.takeIf { LINK.matches(it) }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PollJob.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PollJob.kt
new file mode 100644
index 0000000..0998d00
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PollJob.kt
@@ -0,0 +1,65 @@
+package org.meshbay.client.notify
+
+import android.app.job.JobParameters
+import android.app.job.JobService
+import android.content.Context
+import android.util.Log
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONObject
+import org.meshbay.client.bridge.Bridge
+import org.meshbay.client.hub.HubClient
+import java.util.concurrent.TimeUnit
+
+/**
+ * One fetch of what is new (`POST /v1/push/poll`), page running or not.
+ *
+ * Authenticated by the row's poll secret, never a session: what this keeps for
+ * running in the background reads notification lines and nothing else. It goes
+ * to the hub the application is signed in to, and only there.
+ */
+class PollJob : JobService() {
+ @Volatile private var worker: Thread? = null
+
+ override fun onStartJob(params: JobParameters): Boolean {
+ worker = Thread {
+ try { fetch() } catch (e: Exception) { Log.w(Bridge.TAG, "poll failed: ${e.javaClass.simpleName}") }
+ jobFinished(params, false)
+ }.apply { start() }
+ return true
+ }
+
+ override fun onStopJob(params: JobParameters): Boolean {
+ worker?.interrupt()
+ return false
+ }
+
+ private fun fetch() {
+ val state = PushState(getSharedPreferences(PushState.PREFS, Context.MODE_PRIVATE))
+ val target = state.pollTarget() ?: return
+ val base = HubClient(getSharedPreferences("shell", Context.MODE_PRIVATE)).base
+ val url = base.toHttpUrlOrNull()?.newBuilder()?.encodedPath("/v1/push/poll")?.build() ?: return
+ val body = JSONObject().put("id", target.id).put("secret", target.secret).put("since", target.since)
+ .toString().toRequestBody("application/json".toMediaType())
+ val http = OkHttpClient.Builder().callTimeout(30, TimeUnit.SECONDS).followRedirects(false).build()
+ http.newCall(Request.Builder().url(url).post(body).build()).execute().use { r ->
+ when {
+ // The row is gone — signed out elsewhere, or deleted: the page
+ // registers again when it next runs, if push is still on.
+ r.code == 404 -> state.forgetSubscription()
+ r.isSuccessful -> {
+ val list = JSONObject(r.body.string()).optJSONArray("notifications") ?: return
+ for (i in 0 until list.length()) {
+ val raw = list.optJSONObject(i)?.toString()?.toByteArray() ?: continue
+ val shown = Notifier.parse(raw) ?: continue
+ Notifier.deliver(this, state, shown)
+ state.advance(shown.createdAt)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Poller.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Poller.kt
new file mode 100644
index 0000000..8f58512
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/Poller.kt
@@ -0,0 +1,29 @@
+package org.meshbay.client.notify
+
+import android.app.job.JobInfo
+import android.app.job.JobScheduler
+import android.content.ComponentName
+import android.content.Context
+
+/**
+ * The periodic fetch, through the system's own job scheduler: no library and
+ * nothing the platform does not already run. Android decides the exact moment
+ * (fifteen minutes is the shortest period it allows, and Doze stretches it);
+ * a phone that also gets pushes keeps a slow fetch as a net under them.
+ */
+object Poller {
+ private const val JOB_ID = 4208
+ private const val FETCHING_MS = 15L * 60 * 1000
+ private const val UNDER_PUSH_MS = 4L * 3600 * 1000
+
+ fun schedule(context: Context, pushed: Boolean) {
+ val job = JobInfo.Builder(JOB_ID, ComponentName(context, PollJob::class.java))
+ .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
+ .setPeriodic(if (pushed) UNDER_PUSH_MS else FETCHING_MS)
+ .setPersisted(true)
+ .build()
+ context.getSystemService(JobScheduler::class.java).schedule(job)
+ }
+
+ fun cancel(context: Context) = context.getSystemService(JobScheduler::class.java).cancel(JOB_ID)
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushChannels.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushChannels.kt
new file mode 100644
index 0000000..e8619a5
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushChannels.kt
@@ -0,0 +1,76 @@
+package org.meshbay.client.notify
+
+import android.app.Activity
+import android.app.NotificationManager
+import android.os.Build
+import org.json.JSONArray
+import org.json.JSONObject
+import org.unifiedpush.android.connector.UnifiedPush
+
+/**
+ * Notifications on this phone (§11.3), with nothing to install.
+ *
+ * Turned on, the phone fetches what is new every quarter of an hour (`PollJob`).
+ * If a UnifiedPush distributor is already on the phone — ntfy, or an application
+ * that carries one — it is used as well, and notifications arrive at once; if it
+ * refuses or goes away, the phone simply goes back to fetching. The page gives
+ * the hub whatever this side ends up with. Phone-only: the desktop has no
+ * counterpart, and its preload has no such channels.
+ *
+ * Whether a notification is wanted at all — every one turned off, or one group
+ * muted — is decided on the hub, which then creates nothing, so nothing is
+ * pushed or fetched. Nothing here second-guesses it.
+ */
+class PushChannels(
+ private val activity: Activity,
+ private val state: PushState,
+ private val channelNames: () -> Map<String, String>,
+) {
+ fun handles(channel: String) = channel.startsWith("push:")
+
+ fun call(channel: String, args: JSONArray): Any? = when (channel) {
+ "push:status" -> status()
+ "push:enable" -> enable()
+ "push:disable" -> { disable(); status() }
+ "push:remember" -> {
+ state.remember(args.optString(0, ""), args.optString(1, ""), args.optString(2, ""), args.optString(3, ""))
+ Poller.schedule(activity, pushed = state.endpoint != null)
+ status()
+ }
+ else -> throw org.meshbay.client.bridge.Refused("Refused: no such channel")
+ }
+
+ private fun distributors() = UnifiedPush.getDistributors(activity).any { it != activity.packageName }
+
+ private fun status(): JSONObject = state.status()
+ .put("distributors", distributors())
+ .put("permitted", activity.getSystemService(NotificationManager::class.java).areNotificationsEnabled())
+
+ private fun enable(): JSONObject {
+ state.requested()
+ Notifier.ensureChannels(activity, channelNames())
+ if (!distributors()) state.fellBack("NO_DISTRIBUTOR")
+ activity.runOnUiThread {
+ if (Build.VERSION.SDK_INT >= 33) {
+ activity.requestPermissions(arrayOf(android.Manifest.permission.POST_NOTIFICATIONS), PERMISSION_REQUEST)
+ }
+ // Only when one is installed: the system's chooser for a person who
+ // has none would be a screen about something they never asked for.
+ if (distributors()) UnifiedPush.tryUseDefaultDistributor(activity) { found ->
+ if (found) UnifiedPush.register(activity, messageForDistributor = "MeshBay")
+ else state.fellBack("NO_DISTRIBUTOR")
+ }
+ }
+ return status()
+ }
+
+ private fun disable() {
+ Poller.cancel(activity)
+ try { UnifiedPush.removeDistributor(activity) } catch (e: Exception) { /* none was saved */ }
+ state.cleared()
+ }
+
+ companion object {
+ private const val PERMISSION_REQUEST = 4207
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushReceiver.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushReceiver.kt
new file mode 100644
index 0000000..64ea7a7
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushReceiver.kt
@@ -0,0 +1,41 @@
+package org.meshbay.client.notify
+
+import android.content.Context
+import android.util.Log
+import org.meshbay.client.bridge.Bridge
+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
+
+/**
+ * What a distributor tells this application, page running or not — when the
+ * phone has one. Losing it is not an error: the phone goes back to fetching
+ * (`PollJob`), and the page tells the hub at its next start.
+ *
+ * A message is drawn only if the connector decrypted it with this phone's
+ * key: anything else did not come from a hub holding the subscription.
+ */
+class PushReceiver : PushService() {
+ private fun state() = PushState(getSharedPreferences(PushState.PREFS, Context.MODE_PRIVATE))
+
+ override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) {
+ state().endpoint(endpoint.url, endpoint.pubKeySet?.pubKey, endpoint.pubKeySet?.auth)
+ }
+
+ override fun onMessage(message: PushMessage, instance: String) {
+ val state = state()
+ if (!message.decrypted || !state.enabled) { Log.w(Bridge.TAG, "push message dropped"); return }
+ Notifier.parse(message.content)?.let { Notifier.deliver(this, state, it) }
+ }
+
+ override fun onRegistrationFailed(reason: FailedReason, instance: String) = fallBack(reason.name)
+
+ override fun onUnregistered(instance: String) = fallBack("UNREGISTERED")
+
+ private fun fallBack(reason: String) {
+ val state = state()
+ state.fellBack(reason)
+ if (state.enabled) Poller.schedule(this, pushed = false)
+ }
+}
diff --git a/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushState.kt b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushState.kt
new file mode 100644
index 0000000..3905d58
--- /dev/null
+++ b/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushState.kt
@@ -0,0 +1,126 @@
+package org.meshbay.client.notify
+
+import android.content.SharedPreferences
+import org.json.JSONObject
+
+/**
+ * Where this phone stands with the hub and, when it has one, with a push
+ * distributor — kept across restarts, because both answer whenever they like,
+ * often with the page not running.
+ *
+ * Turned on, it is always `ready` in the end: with an endpoint when a
+ * distributor gave one (pushed, instantly), without one otherwise (fetched,
+ * every quarter of an hour). Nothing to install is the default; a distributor
+ * is a bonus, and losing it falls back rather than failing.
+ *
+ * `registered` is the endpoint the hub was last given (null for none). When it
+ * differs from `endpoint` the page registers again; that is the whole of what
+ * keeps the hub's row current.
+ */
+class PushState(private val prefs: SharedPreferences) {
+
+ fun status(): JSONObject = JSONObject()
+ .put("enabled", enabled)
+ .put("state", prefs.getString(STATE, OFF))
+ .put("reason", prefs.getString(REASON, null) ?: JSONObject.NULL)
+ .put("endpoint", endpoint ?: JSONObject.NULL)
+ .put("p256dh", prefs.getString(P256DH, null) ?: JSONObject.NULL)
+ .put("auth", prefs.getString(AUTH, null) ?: JSONObject.NULL)
+ .put("subscription", prefs.getString(SUBSCRIPTION, null) ?: JSONObject.NULL)
+ .put("account", prefs.getString(ACCOUNT, null) ?: JSONObject.NULL)
+ .put("registered", prefs.getString(REGISTERED, null) ?: JSONObject.NULL)
+
+ val enabled get() = prefs.getBoolean(ENABLED, false)
+ val endpoint: String? get() = prefs.getString(ENDPOINT, null)
+
+ fun requested() = prefs.edit().putBoolean(ENABLED, true).putString(STATE, PENDING).remove(REASON).apply()
+
+ fun endpoint(url: String, p256dh: String?, auth: String?) {
+ if (!enabled) return
+ // A distributor speaking only the old protocol gives no keys, and
+ // nothing could be encrypted to it: fetch instead.
+ if (p256dh == null || auth == null) { fellBack("NO_KEYS"); return }
+ prefs.edit().putString(STATE, READY).remove(REASON)
+ .putString(ENDPOINT, url).putString(P256DH, p256dh).putString(AUTH, auth).apply()
+ }
+
+ /** No distributor, or it refused or dropped us: fetch, and say why. */
+ fun fellBack(reason: String) {
+ if (!enabled) return
+ prefs.edit().putString(STATE, READY).putString(REASON, reason)
+ .remove(ENDPOINT).remove(P256DH).remove(AUTH).apply()
+ }
+
+ /** What the hub answered a registration: its row, the account, the poll secret, its clock. */
+ fun remember(subscription: String, account: String, secret: String, since: String) {
+ require(SUBSCRIPTION_ID.matches(subscription) && ACCOUNT_ID.matches(account) &&
+ SECRET.matches(secret) && SINCE.matches(since)) { "bad subscription" }
+ prefs.edit().putString(SUBSCRIPTION, subscription).putString(ACCOUNT, account)
+ .putString(SECRET_KEY, secret).putString(SINCE_KEY, since)
+ .putString(REGISTERED, endpoint).apply()
+ }
+
+ /** The hub no longer knows this row: the page registers again when it next runs. */
+ fun forgetSubscription() = prefs.edit().remove(SUBSCRIPTION).remove(SECRET_KEY).remove(REGISTERED).apply()
+
+ data class PollTarget(val id: String, val secret: String, val since: String)
+
+ fun pollTarget(): PollTarget? {
+ if (!enabled) return null
+ return PollTarget(prefs.getString(SUBSCRIPTION, null) ?: return null,
+ prefs.getString(SECRET_KEY, null) ?: return null,
+ prefs.getString(SINCE_KEY, null) ?: return null)
+ }
+
+ /** Fetch from here next time. ISO-8601 from the hub's own clock, so they compare as text. */
+ fun advance(cursor: String) {
+ if (SINCE.matches(cursor) && cursor > (prefs.getString(SINCE_KEY, "") ?: "")) {
+ prefs.edit().putString(SINCE_KEY, cursor).apply()
+ }
+ }
+
+ /**
+ * True the first time this line is seen at this date — pushed and then
+ * fetched, it is drawn once. A conversation keeps its id and moves its
+ * date, so a newer date is news again.
+ */
+ @Synchronized
+ fun firstSight(id: Long, createdAt: String): Boolean {
+ val seen = try { JSONObject(prefs.getString(SEEN, "{}") ?: "{}") } catch (e: Exception) { JSONObject() }
+ val key = id.toString()
+ if (seen.optString(key, "") >= createdAt) return false
+ seen.put(key, createdAt)
+ if (seen.length() > SEEN_MAX) {
+ seen.keys().asSequence().toList().sortedBy { seen.optString(it) }
+ .take(seen.length() - SEEN_MAX).forEach { seen.remove(it) }
+ }
+ prefs.edit().putString(SEEN, seen.toString()).apply()
+ return true
+ }
+
+ fun cleared() = prefs.edit().clear().apply()
+
+ companion object {
+ const val OFF = "off"
+ const val PENDING = "pending"
+ const val READY = "ready"
+ private const val ENABLED = "enabled"
+ private const val STATE = "state"
+ private const val REASON = "reason"
+ private const val ENDPOINT = "endpoint"
+ private const val P256DH = "p256dh"
+ private const val AUTH = "auth"
+ private const val SUBSCRIPTION = "subscription"
+ private const val ACCOUNT = "account"
+ private const val REGISTERED = "registered"
+ private const val SECRET_KEY = "pollSecret"
+ private const val SINCE_KEY = "since"
+ private const val SEEN = "seen"
+ private const val SEEN_MAX = 100
+ private val SUBSCRIPTION_ID = Regex("^[0-9a-f-]{36}$")
+ private val ACCOUNT_ID = Regex("^[0-9A-Za-z-]{1,64}$")
+ private val SECRET = Regex("^[A-Za-z0-9_-]{20,64}$")
+ private val SINCE = Regex("^\\d{4}-\\d\\d-\\d\\dT[0-9:.]+(\\+00:00|Z)$")
+ const val PREFS = "push"
+ }
+}