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