aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-android/app/src/main/kotlin/org/meshbay/client/notify/PushState.kt
blob: 3905d5844b7f937dc14cfe5412140658ec7d377e (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
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"
    }
}