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
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
|
package org.meshbay.client.phonesync
import android.app.Activity
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.webkit.WebResourceResponse
import org.json.JSONArray
import org.json.JSONObject
import org.meshbay.client.bridge.Refused
import org.meshbay.client.photos.PhotoChannels
import java.io.File
import java.io.FileInputStream
import java.security.SecureRandom
import java.util.TimeZone
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
/**
* Contacts or messages backup (docs/MESHBAY_DESIGN.md §9.13): what the page
* needs from the phone for one kind of data, and nothing it could use to read
* anything else.
*
* The same split as photos (PhotoChannels): the page decides when and does the
* sending; this side writes what is new into one file and hands it over by an
* opaque token at `/phonesync/<token>`, valid until the next plan. What the
* next file starts from (`marker`) moves only when the node has taken this one.
*
* Where the file goes is the destination every kind shares (Destination.kt);
* what is kept here is only whether this kind is on, and what was sent.
* `prefix` names the channels (`contactsync:status`, …).
*/
class DocChannels(
private val prefix: String,
private val activity: Activity,
private val prefs: SharedPreferences,
private val destinations: DestinationChannels,
private val dir: File,
private val source: DocSource,
private val notifyId: Int,
private val permissionRequest: Int,
) {
private val random = SecureRandom()
@Volatile private var issued: Issued? = null
@Volatile private var permission: CountDownLatch? = null
private class Issued(val token: String, val export: Export, val key: String)
init {
// Somewhere new starts from nothing: the first file there holds everything.
destinations.onChange {
issued = null
prefs.edit().remove(MARKER).remove(SENT).remove(LAST_SENT).remove(LAST)
.remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply()
}
}
fun handles(channel: String) = channel.startsWith("$prefix:")
fun call(channel: String, args: JSONArray): Any? = when (channel.removePrefix("$prefix:")) {
"status" -> status()
"permit" -> { permit(); status() }
"configure" -> { configure(args.optJSONObject(0)); status() }
"plan" -> { requirePermission(); plan() }
"sent" -> { sent(args.optString(0, ""), args.optString(1, ""), args.optString(2, "")); true }
"completed" -> { completed(); status() }
"failed" -> failed(args.optString(0, ""), args.optString(1, ""))
"open-settings" -> { openSettings(); true }
else -> throw Refused("Refused: no such channel")
}
// ── state ────────────────────────────────────────────────────────────────
/** On, and somewhere to go: the destination, or null. */
private fun active(): Destination? = if (prefs.getBoolean(ON, false)) destinations.get() else null
fun status(): JSONObject {
val d = destinations.get()
return JSONObject()
.put("permission", if (permitted()) "granted" else "denied")
.put("on", prefs.getBoolean(ON, false))
.put("destination", d?.toJson() ?: JSONObject.NULL)
.put("dir", d?.let { DocPlan.dirFor(it, source.suffix) } ?: JSONObject.NULL)
.put("lastCompleted", if (prefs.contains(LAST)) prefs.getLong(LAST, 0) else JSONObject.NULL)
.put("lastSent", prefs.getString(LAST_SENT, null) ?: JSONObject.NULL)
.put("failure", prefs.getString(FAILURE, null) ?: JSONObject.NULL)
.put("failureAt", if (prefs.contains(FAILURE_AT)) prefs.getLong(FAILURE_AT, 0) else JSONObject.NULL)
.put("sent", prefs.getInt(SENT, 0))
.put("now", System.currentTimeMillis())
}
/** `{}` turns it on, null off; turned off, it forgets what it sent. */
private fun configure(o: JSONObject?) {
issued = null
if (o == null) {
prefs.edit().clear().apply()
return
}
if (destinations.get() == null) throw Refused("Refused: no destination")
prefs.edit().putBoolean(ON, true).apply()
}
private fun completed() {
prefs.edit().putLong(LAST, System.currentTimeMillis()).remove(FAILURE).remove(FAILURE_AT).remove(NOTIFIED).apply()
}
/** A run stopped for a reason that will hold tomorrow too; said once. True when this call said it. */
private fun failed(code: String, text: String): Boolean {
val c = code.take(64)
prefs.edit().putString(FAILURE, c).putLong(FAILURE_AT, System.currentTimeMillis()).apply()
if (prefs.getString(NOTIFIED, null) == c || text.isBlank()) return false
prefs.edit().putString(NOTIFIED, c).apply()
PhotoChannels.notice(activity, notifyId, text.take(300))
return true
}
// ── the file ─────────────────────────────────────────────────────────────
private fun plan(): JSONObject {
val c = active() ?: throw Refused("Refused: this backup is off")
issued = null
dir.listFiles()?.forEach { it.delete() }
dir.mkdirs()
val export = source.export(prefs.getString(MARKER, null), dir, System.currentTimeMillis(), TimeZone.getDefault())
?: return JSONObject().put("item", JSONObject.NULL)
val token = PhotoChannels.hex(ByteArray(16).also { random.nextBytes(it) })
issued = Issued(token, export, c.ledgerKey)
return JSONObject().put("item", JSONObject()
.put("token", token).put("name", export.name)
.put("dir", DocPlan.dirFor(c, source.suffix) + (if (export.subdir.isEmpty()) "" else "/${export.subdir}"))
.put("size", export.file.length()).put("count", export.count))
}
/** The node took it: what the next file starts from moves, never before. */
private fun sent(token: String, dir: String, name: String) {
val i = issued?.takeIf { it.token == token } ?: throw Refused("Refused: unknown file")
if (active()?.ledgerKey != i.key) throw Refused("Refused: the backup changed")
prefs.edit().putString(MARKER, i.export.marker).putInt(SENT, prefs.getInt(SENT, 0) + 1)
.putString(LAST_SENT, "${dir.take(1024)}/${name.take(256)}").apply()
issued = null
i.export.file.delete()
}
/** The bytes of the issued file, for `/phonesync/<token>` on the packaged origin. */
fun serve(path: String): WebResourceResponse? {
val i = issued?.takeIf { it.token == path.removePrefix(PATH) } ?: return null
val stream = try { FileInputStream(i.export.file) } catch (e: Exception) { return null }
val headers = mapOf("Cache-Control" to "no-store", "X-Content-Type-Options" to "nosniff")
return WebResourceResponse(i.export.mime, null, 200, "OK", headers, stream)
}
// ── permission ───────────────────────────────────────────────────────────
private fun permitted() = source.permissions.all {
activity.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
}
private fun requirePermission() {
if (!permitted()) throw Refused("Refused: no access")
}
/** Asks, and waits for the answer: the page goes on from what was decided. */
private fun permit() {
if (permitted()) return
val latch = CountDownLatch(1)
permission = latch
activity.runOnUiThread { activity.requestPermissions(source.permissions, permissionRequest) }
latch.await(5, TimeUnit.MINUTES)
permission = null
}
/**
* The application's page in the system's settings. Android keeps some
* permissions (text messages) behind "restricted settings" for an
* application installed outside a store; that page is where the person
* lifts it (⋮ > Allow restricted settings), and nowhere in here can.
*/
private fun openSettings() {
val intent = android.content.Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
android.net.Uri.fromParts("package", activity.packageName, null))
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
activity.runOnUiThread { activity.startActivity(intent) }
}
/** From Activity.onRequestPermissionsResult; true when the request was ours. */
fun deliverPermission(requestCode: Int): Boolean {
if (requestCode != permissionRequest) return false
permission?.countDown()
return true
}
companion object {
const val PATH = "/phonesync/"
private const val ON = "on"
private const val MARKER = "marker"
private const val SENT = "sent"
private const val LAST_SENT = "last_sent"
private const val LAST = "last_completed"
private const val FAILURE = "failure"
private const val FAILURE_AT = "failure_at"
private const val NOTIFIED = "notified"
}
}
|