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" } }