craft-native 0.0.167 → 0.0.169
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/android/src/index.d.ts +62 -0
- package/dist/android/src/index.js +138 -18
- package/dist/android/templates/CraftNativeBackground.kt.template +296 -0
- package/dist/android/templates/CraftNativeCapabilities.kt.template +59 -37
- package/dist/android/templates/CraftNativeHealth.kt.template +1046 -0
- package/dist/android/templates/CraftNativeHealthStub.kt.template +32 -0
- package/dist/android/templates/CraftNativeMessagingService.kt.template +75 -0
- package/dist/android/templates/CraftNativeMutation.kt.template +14 -2
- package/dist/android/templates/CraftNativeServices.kt.template +561 -0
- package/dist/android/templates/CraftNativeShell.kt.template +1013 -0
- package/dist/android/templates/LocationRecordingService.kt.template +7 -0
- package/dist/android/templates/MainActivityNative.kt.template +738 -132
- package/dist/android/templates/build.gradle.kts.app.template +16 -0
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftNativeScreen.swift +22 -9
- package/package.json +1 -1
|
@@ -0,0 +1,1013 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.content.SharedPreferences
|
|
5
|
+
import android.content.res.ColorStateList
|
|
6
|
+
import android.graphics.Color
|
|
7
|
+
import android.graphics.Typeface
|
|
8
|
+
import android.graphics.drawable.GradientDrawable
|
|
9
|
+
import android.net.Uri
|
|
10
|
+
import android.os.Handler
|
|
11
|
+
import android.os.Looper
|
|
12
|
+
import android.text.TextUtils
|
|
13
|
+
import android.util.Log
|
|
14
|
+
import android.util.TypedValue
|
|
15
|
+
import android.view.Gravity
|
|
16
|
+
import android.view.View
|
|
17
|
+
import android.view.ViewGroup
|
|
18
|
+
import android.widget.FrameLayout
|
|
19
|
+
import android.widget.ImageView
|
|
20
|
+
import android.widget.LinearLayout
|
|
21
|
+
import android.widget.TextView
|
|
22
|
+
import androidx.core.content.ContextCompat
|
|
23
|
+
import androidx.security.crypto.EncryptedSharedPreferences
|
|
24
|
+
import androidx.security.crypto.MasterKey
|
|
25
|
+
import org.json.JSONArray
|
|
26
|
+
import org.json.JSONObject
|
|
27
|
+
import java.io.File
|
|
28
|
+
import java.net.URLDecoder
|
|
29
|
+
import java.net.URLEncoder
|
|
30
|
+
import java.util.concurrent.ConcurrentHashMap
|
|
31
|
+
import java.util.concurrent.Executors
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The app around the native screens: what `nativeScreens`, `tabs`,
|
|
35
|
+
* `nativeApp` and `shareStorage` in craft.config.json describe, the same
|
|
36
|
+
* contract the iOS host reads (CraftNativeScreen.swift, CraftHybrid.swift).
|
|
37
|
+
*
|
|
38
|
+
* - [CraftNativeRoutes] turns an app path (`/m/workout/42?tab=files`) into
|
|
39
|
+
* the screen that draws it and its params, fixed segments before
|
|
40
|
+
* parameters, so `/m/store/library` is never taken for a product's slug.
|
|
41
|
+
* - [CraftNativeStores] keeps what a screen reads synchronously on iOS
|
|
42
|
+
* (`craft.storage.getSync`, `craft.snapshots.get`,
|
|
43
|
+
* `craft.secureStorage.getSync`). Android's JavaScriptSandbox cannot call
|
|
44
|
+
* the host synchronously, so each isolate starts with a copy of these
|
|
45
|
+
* stores ([CraftNativeStores.preload]) and the host keeps every copy
|
|
46
|
+
* current: a write in one isolate reaches the others as `SYNC_UPDATE`.
|
|
47
|
+
* - [CraftNativeHub] is every live isolate in the process (screens, the app
|
|
48
|
+
* script, a background refresh), so a change reaches all of them.
|
|
49
|
+
* - [CraftNativeChrome] is the navigation bar above a screen and the tab bar
|
|
50
|
+
* below it, drawn with plain Android views.
|
|
51
|
+
*/
|
|
52
|
+
internal object CraftNativeHub {
|
|
53
|
+
private val receivers = ConcurrentHashMap<String, (String, JSONObject) -> Unit>()
|
|
54
|
+
private val main = Handler(Looper.getMainLooper())
|
|
55
|
+
|
|
56
|
+
fun register(id: String, receiver: (String, JSONObject) -> Unit) {
|
|
57
|
+
receivers[id] = receiver
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
fun unregister(id: String) {
|
|
61
|
+
receivers.remove(id)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Sends to every registered isolate but `except`, on the main thread. */
|
|
65
|
+
fun broadcast(type: String, payload: JSONObject, except: String? = null) {
|
|
66
|
+
val deliver = {
|
|
67
|
+
receivers.forEach { (id, receiver) ->
|
|
68
|
+
if (id != except) runCatching { receiver(type, payload) }
|
|
69
|
+
.onFailure { Log.w("CraftNativeAndroid", "Broadcast $type to $id failed", it) }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (Looper.myLooper() == Looper.getMainLooper()) deliver() else main.post(deliver)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A screen of the app found for a path. */
|
|
77
|
+
internal data class CraftNativeRouteMatch(val screen: String, val params: JSONObject, val path: String)
|
|
78
|
+
|
|
79
|
+
internal data class CraftNativeTab(val id: String, val title: String, val symbol: String)
|
|
80
|
+
|
|
81
|
+
/** The app's paths (`nativeScreens`), its tabs and its site. */
|
|
82
|
+
internal class CraftNativeRoutes(config: JSONObject) {
|
|
83
|
+
private data class Route(val segments: List<String>, val screen: String)
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The iOS shell's order (CraftHybrid.swift's route table): a trailing
|
|
87
|
+
* `*` last, then more fixed segments first, then longer patterns, then
|
|
88
|
+
* by name, so `/m/store/library` is never taken for `/m/store/:slug`.
|
|
89
|
+
*/
|
|
90
|
+
private val routes: List<Route> = buildList {
|
|
91
|
+
val screens = config.optJSONObject("nativeScreens") ?: JSONObject()
|
|
92
|
+
screens.keys().forEach { pattern ->
|
|
93
|
+
val screen = screens.optString(pattern)
|
|
94
|
+
if (screen.isNotBlank()) add(Route(segmentsOf(pattern), screen))
|
|
95
|
+
}
|
|
96
|
+
}.sortedWith(
|
|
97
|
+
compareBy<Route> { if (it.segments.lastOrNull() == "*") 1 else 0 }
|
|
98
|
+
.thenByDescending { route -> route.segments.count { it != "*" && !it.startsWith(":") } }
|
|
99
|
+
.thenByDescending { it.segments.size }
|
|
100
|
+
.thenBy { it.segments.joinToString("/") },
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
val tabs: List<CraftNativeTab> = buildList {
|
|
104
|
+
val list = config.optJSONArray("tabs") ?: JSONArray()
|
|
105
|
+
for (index in 0 until list.length()) {
|
|
106
|
+
val tab = list.optJSONObject(index) ?: continue
|
|
107
|
+
val id = tab.optString("id").takeIf { it.isNotBlank() } ?: continue
|
|
108
|
+
add(CraftNativeTab(normalize(id), tab.optString("title", id), tab.optString("symbol")))
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The app's site (`devServerURL`): its origin answers paths no screen draws. */
|
|
113
|
+
val site: Uri? = config.optString("devServerURL").takeIf { it.isNotBlank() }?.let(Uri::parse)
|
|
114
|
+
val siteOrigin: String? = site?.let { uri ->
|
|
115
|
+
val port = if (uri.port > 0) ":${uri.port}" else ""
|
|
116
|
+
"${uri.scheme}://${uri.host}$port"
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Where the app opens: the site's path when a screen draws it, else the first tab. */
|
|
120
|
+
val initialPath: String = site?.path?.takeIf { it.isNotBlank() && match(it) != null }
|
|
121
|
+
?: tabs.firstOrNull()?.id
|
|
122
|
+
?: "/"
|
|
123
|
+
|
|
124
|
+
val isEmpty: Boolean get() = routes.isEmpty()
|
|
125
|
+
|
|
126
|
+
/** The tab whose root is `path` (without its query), if any. */
|
|
127
|
+
fun tabFor(path: String): CraftNativeTab? {
|
|
128
|
+
val bare = normalize(path.substringBefore('?').substringBefore('#'))
|
|
129
|
+
return tabs.firstOrNull { it.id == bare }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The path inside the app for `target`: a path as it is, or an address on
|
|
134
|
+
* the app's own site (or its URL scheme, `hqtraining://m/connections`) as
|
|
135
|
+
* its path and query. Another site's address is not the app's: null.
|
|
136
|
+
*/
|
|
137
|
+
fun appPath(target: String, schemes: Collection<String>): String? {
|
|
138
|
+
if (target.startsWith("/")) return target
|
|
139
|
+
val uri = runCatching { Uri.parse(target) }.getOrNull() ?: return null
|
|
140
|
+
val scheme = uri.scheme?.lowercase() ?: return null
|
|
141
|
+
if (scheme in schemes) {
|
|
142
|
+
// hqtraining://m/connections: the host is the first segment.
|
|
143
|
+
val path = "/" + listOfNotNull(uri.host?.takeIf { it.isNotBlank() }, uri.path?.trim('/')?.takeIf { it.isNotBlank() })
|
|
144
|
+
.joinToString("/")
|
|
145
|
+
return path + (uri.encodedQuery?.let { "?$it" } ?: "")
|
|
146
|
+
}
|
|
147
|
+
val origin = siteOrigin ?: return null
|
|
148
|
+
val port = if (uri.port > 0) ":${uri.port}" else ""
|
|
149
|
+
if ("${uri.scheme}://${uri.host}$port" != origin) return null
|
|
150
|
+
return (uri.encodedPath ?: "/") + (uri.encodedQuery?.let { "?$it" } ?: "")
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The screen that draws `path`, with its parameters and query as params. */
|
|
154
|
+
fun match(path: String): CraftNativeRouteMatch? {
|
|
155
|
+
val bare = normalize(path.substringBefore('#').substringBefore('?'))
|
|
156
|
+
val query = path.substringBefore('#').substringAfter('?', "")
|
|
157
|
+
val parts = segmentsOf(bare)
|
|
158
|
+
for (route in routes) {
|
|
159
|
+
val params = bind(route.segments, parts) ?: continue
|
|
160
|
+
if (query.isNotBlank()) {
|
|
161
|
+
query.split('&').filter { it.isNotBlank() }.forEach { pair ->
|
|
162
|
+
val key = decode(pair.substringBefore('='))
|
|
163
|
+
if (key.isNotBlank() && !params.has(key)) params.put(key, decode(pair.substringAfter('=', "").replace('+', ' ')))
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return CraftNativeRouteMatch(route.screen, params, bare)
|
|
167
|
+
}
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private fun bind(pattern: List<String>, parts: List<String>): JSONObject? {
|
|
172
|
+
val params = JSONObject()
|
|
173
|
+
for (index in pattern.indices) {
|
|
174
|
+
val expected = pattern[index]
|
|
175
|
+
if (expected == "*") {
|
|
176
|
+
params.put("*", parts.drop(index).joinToString("/"))
|
|
177
|
+
return params
|
|
178
|
+
}
|
|
179
|
+
if (index >= parts.size) return null
|
|
180
|
+
if (expected.startsWith(":")) {
|
|
181
|
+
if (parts[index].isBlank()) return null
|
|
182
|
+
params.put(expected.removePrefix(":"), decode(parts[index]))
|
|
183
|
+
} else if (expected != parts[index]) return null
|
|
184
|
+
}
|
|
185
|
+
return if (pattern.size == parts.size) params else null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The tab a path belongs under (`/m/store/42` → `/m/store`), for a link opened from outside. */
|
|
189
|
+
fun tabOwning(path: String): CraftNativeTab? {
|
|
190
|
+
val bare = normalize(path.substringBefore('?').substringBefore('#'))
|
|
191
|
+
return tabs.filter { tab -> bare == tab.id || bare.startsWith(tab.id.trimEnd('/') + "/") }
|
|
192
|
+
.maxByOrNull { it.id.length }
|
|
193
|
+
?.takeIf { it.id != "/" }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** An address on the site for a path no screen draws. */
|
|
197
|
+
fun siteURL(path: String): String? = siteOrigin?.let { it + (if (path.startsWith("/")) path else "/$path") }
|
|
198
|
+
|
|
199
|
+
companion object {
|
|
200
|
+
fun normalize(path: String): String {
|
|
201
|
+
val trimmed = path.trim().ifBlank { "/" }
|
|
202
|
+
val withSlash = if (trimmed.startsWith("/")) trimmed else "/$trimmed"
|
|
203
|
+
return if (withSlash.length > 1) withSlash.trimEnd('/') else withSlash
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private fun segmentsOf(path: String): List<String> =
|
|
207
|
+
normalize(path).split('/').filter { it.isNotBlank() }
|
|
208
|
+
|
|
209
|
+
private fun decode(value: String): String =
|
|
210
|
+
runCatching { URLDecoder.decode(value, "UTF-8") }.getOrDefault(value)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The stores a screen reads synchronously, shared by the whole process:
|
|
216
|
+
*
|
|
217
|
+
* - `storage`: the `craft-native-storage` preferences behind `craft.storage`
|
|
218
|
+
* (JSON text per key, CraftNativeCapabilities.storage).
|
|
219
|
+
* - `secure`: the encrypted preferences behind `craft.secureStorage`.
|
|
220
|
+
* - `snapshots`: `files/craft-snapshots/<name>.json`, what a screen keeps for
|
|
221
|
+
* its next first frame and for no signal (`craft.snapshots`).
|
|
222
|
+
*
|
|
223
|
+
* Writes happen on one background thread, in order.
|
|
224
|
+
*/
|
|
225
|
+
internal class CraftNativeStores private constructor(context: Context, private val secureEnabled: Boolean) {
|
|
226
|
+
private val appContext = context.applicationContext
|
|
227
|
+
private val storage: SharedPreferences = appContext.getSharedPreferences("craft-native-storage", Context.MODE_PRIVATE)
|
|
228
|
+
private val snapshotsDir = File(appContext.filesDir, "craft-snapshots").apply { mkdirs() }
|
|
229
|
+
private val writer = Executors.newSingleThreadExecutor()
|
|
230
|
+
private val main = Handler(Looper.getMainLooper())
|
|
231
|
+
|
|
232
|
+
private val secure: SharedPreferences? by lazy {
|
|
233
|
+
if (!secureEnabled) return@lazy null
|
|
234
|
+
runCatching {
|
|
235
|
+
val masterKey = MasterKey.Builder(appContext).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
|
|
236
|
+
EncryptedSharedPreferences.create(
|
|
237
|
+
appContext,
|
|
238
|
+
"craft_secure_prefs",
|
|
239
|
+
masterKey,
|
|
240
|
+
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
|
241
|
+
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
|
242
|
+
)
|
|
243
|
+
}.onFailure { Log.e("CraftNativeAndroid", "Secure storage could not be opened", it) }.getOrNull()
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The snapshots, read from disk once and kept as their JSON text: every
|
|
248
|
+
* isolate starts with all of them (a screen reads one synchronously), and
|
|
249
|
+
* reading the folder for each new screen took longer than a frame.
|
|
250
|
+
*/
|
|
251
|
+
private val snapshotCache: MutableMap<String, String> by lazy {
|
|
252
|
+
val cache = ConcurrentHashMap<String, String>()
|
|
253
|
+
snapshotsDir.listFiles()?.forEach { file ->
|
|
254
|
+
if (!file.name.endsWith(".json")) return@forEach
|
|
255
|
+
val name = snapshotName(file) ?: return@forEach
|
|
256
|
+
runCatching { cache[name] = file.readText() }
|
|
257
|
+
}
|
|
258
|
+
cache
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Everything an isolate needs before its first line runs, as JSON text:
|
|
263
|
+
* `{ storage: { key: json }, secure: { key: value }, snapshots: { name: json } }`.
|
|
264
|
+
* Values stay JSON text; the isolate parses what it reads. Built off the
|
|
265
|
+
* main thread (it can run to megabytes with a few weeks of sessions kept).
|
|
266
|
+
*/
|
|
267
|
+
fun preloadText(): String {
|
|
268
|
+
val out = StringBuilder(64 * 1024)
|
|
269
|
+
fun section(name: String, values: Map<String, String>) {
|
|
270
|
+
out.append(JSONObject.quote(name)).append(":{")
|
|
271
|
+
var first = true
|
|
272
|
+
for ((key, value) in values) {
|
|
273
|
+
if (!first) out.append(',')
|
|
274
|
+
first = false
|
|
275
|
+
out.append(JSONObject.quote(key)).append(':').append(JSONObject.quote(value))
|
|
276
|
+
}
|
|
277
|
+
out.append('}')
|
|
278
|
+
}
|
|
279
|
+
out.append('{')
|
|
280
|
+
section("storage", storage.all.mapNotNull { (key, value) -> (value as? String)?.let { key to it } }.toMap())
|
|
281
|
+
out.append(',')
|
|
282
|
+
section("secure", runCatching { secure?.all?.mapNotNull { (key, value) -> (value as? String)?.let { key to it } }?.toMap() }.getOrNull() ?: emptyMap())
|
|
283
|
+
out.append(',')
|
|
284
|
+
section("snapshots", snapshotCache)
|
|
285
|
+
out.append('}')
|
|
286
|
+
return out.toString()
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The same, parsed (tests and small hosts). */
|
|
290
|
+
fun preload(): JSONObject = JSONObject(preloadText())
|
|
291
|
+
|
|
292
|
+
fun snapshot(name: String): String? = snapshotCache[name]
|
|
293
|
+
|
|
294
|
+
/** `craft.storage.setSync`: null removes the key. Tells every isolate. */
|
|
295
|
+
fun setStorage(key: String, json: String?, from: String?) {
|
|
296
|
+
if (json == null) storage.edit().remove(key).apply() else storage.edit().putString(key, json).apply()
|
|
297
|
+
changed("storage", key, json, from)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** `craft.snapshots.set`: null deletes it. `done` hears whether it was kept, on the main thread. */
|
|
301
|
+
fun setSnapshot(name: String, json: String?, from: String?, done: (Boolean) -> Unit) {
|
|
302
|
+
writer.execute {
|
|
303
|
+
val file = snapshotFile(name)
|
|
304
|
+
val ok = runCatching {
|
|
305
|
+
if (json == null) {
|
|
306
|
+
!file.exists() || file.delete()
|
|
307
|
+
} else {
|
|
308
|
+
val temporary = File(file.parentFile, "${file.name}.tmp")
|
|
309
|
+
temporary.writeText(json)
|
|
310
|
+
temporary.renameTo(file) || run { file.writeText(json); temporary.delete(); true }
|
|
311
|
+
}
|
|
312
|
+
}.getOrDefault(false)
|
|
313
|
+
if (ok) {
|
|
314
|
+
if (json == null) snapshotCache.remove(name) else snapshotCache[name] = json
|
|
315
|
+
}
|
|
316
|
+
main.post {
|
|
317
|
+
if (ok) changed("snapshots", name, json, null)
|
|
318
|
+
done(ok)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** `craft.snapshots.clear(keep)`: every snapshot but those named, as a sign-out does. */
|
|
324
|
+
fun clearSnapshots(keep: Set<String>, done: (Boolean) -> Unit) {
|
|
325
|
+
writer.execute {
|
|
326
|
+
val removed = mutableListOf<String>()
|
|
327
|
+
var ok = true
|
|
328
|
+
snapshotsDir.listFiles()?.forEach { file ->
|
|
329
|
+
if (!file.name.endsWith(".json")) return@forEach
|
|
330
|
+
val name = snapshotName(file) ?: return@forEach
|
|
331
|
+
if (name in keep) return@forEach
|
|
332
|
+
if (file.delete()) removed.add(name) else ok = false
|
|
333
|
+
}
|
|
334
|
+
removed.forEach(snapshotCache::remove)
|
|
335
|
+
main.post {
|
|
336
|
+
removed.forEach { changed("snapshots", it, null, null) }
|
|
337
|
+
done(ok)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** A change made through the capability API (`Storage`, `SecureStorage`): every isolate's copy follows it. */
|
|
343
|
+
fun changed(store: String, key: String?, value: String?, from: String?) {
|
|
344
|
+
val payload = JSONObject().put("store", store)
|
|
345
|
+
if (key == null) payload.put("clear", true) else payload.put("key", key).put("value", value ?: JSONObject.NULL)
|
|
346
|
+
CraftNativeHub.broadcast("SYNC_UPDATE", payload, except = from)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private fun snapshotFile(name: String): File = File(snapshotsDir, URLEncoder.encode(name, "UTF-8") + ".json")
|
|
350
|
+
|
|
351
|
+
private fun snapshotName(file: File): String? =
|
|
352
|
+
runCatching { URLDecoder.decode(file.name.removeSuffix(".json"), "UTF-8") }.getOrNull()
|
|
353
|
+
|
|
354
|
+
companion object {
|
|
355
|
+
@Volatile private var shared: CraftNativeStores? = null
|
|
356
|
+
|
|
357
|
+
fun get(context: Context, config: JSONObject): CraftNativeStores = shared ?: synchronized(this) {
|
|
358
|
+
shared ?: CraftNativeStores(context, config.optBoolean("enableSecureStorage")).also { shared = it }
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* The script every isolate runs before the bundle: the bridge object stx's
|
|
365
|
+
* runtime reads, the synchronous stores, and the routing of `SYNC_*`
|
|
366
|
+
* messages that keep them current. `post` is how the bridge sends a string
|
|
367
|
+
* to the host in this isolate (a message port or the console).
|
|
368
|
+
*/
|
|
369
|
+
internal object CraftNativePrelude {
|
|
370
|
+
fun script(
|
|
371
|
+
bridgeFields: String,
|
|
372
|
+
post: String,
|
|
373
|
+
receiveSetup: String,
|
|
374
|
+
stores: String,
|
|
375
|
+
routePrelude: String,
|
|
376
|
+
): String = template(bridgeFields, post, receiveSetup, routePrelude).replace(STORES, stores)
|
|
377
|
+
|
|
378
|
+
/** Where the stores go: put in after the (small) script is laid out, so megabytes are never re-indented. */
|
|
379
|
+
private const val STORES = "__CRAFT_PRELOADED_STORES__"
|
|
380
|
+
|
|
381
|
+
private fun template(
|
|
382
|
+
bridgeFields: String,
|
|
383
|
+
post: String,
|
|
384
|
+
receiveSetup: String,
|
|
385
|
+
routePrelude: String,
|
|
386
|
+
): String = """
|
|
387
|
+
$routePrelude
|
|
388
|
+
(function() {
|
|
389
|
+
var forward = null;
|
|
390
|
+
var queued = [];
|
|
391
|
+
var sync = { storage: {}, secure: {}, snapshots: {} };
|
|
392
|
+
var preload = $STORES;
|
|
393
|
+
['storage', 'secure', 'snapshots'].forEach(function(store) {
|
|
394
|
+
var given = preload[store] || {};
|
|
395
|
+
Object.keys(given).forEach(function(key) { sync[store][key] = given[key]; });
|
|
396
|
+
});
|
|
397
|
+
var waiting = {};
|
|
398
|
+
var nextWait = 0;
|
|
399
|
+
// JavaScriptSandbox has no timers: the host keeps them (TIMER /
|
|
400
|
+
// TIMER_CLEAR out, TIMER_FIRE back), as Craft's iOS host gives
|
|
401
|
+
// JavaScriptCore its own.
|
|
402
|
+
var timers = {};
|
|
403
|
+
var nextTimer = 0;
|
|
404
|
+
function arm(fn, ms, args, every) {
|
|
405
|
+
if (typeof fn !== 'function') return 0;
|
|
406
|
+
var id = ++nextTimer;
|
|
407
|
+
var delay = Math.max(0, Number(ms) || 0);
|
|
408
|
+
timers[id] = { fn: fn, args: args, every: every ? Math.max(1, delay) : null };
|
|
409
|
+
send(JSON.stringify({ type: 'TIMER', payload: { id: id, ms: delay } }));
|
|
410
|
+
return id;
|
|
411
|
+
}
|
|
412
|
+
function disarm(id) {
|
|
413
|
+
if (!timers[id]) return;
|
|
414
|
+
delete timers[id];
|
|
415
|
+
send(JSON.stringify({ type: 'TIMER_CLEAR', payload: { id: id } }));
|
|
416
|
+
}
|
|
417
|
+
// requestAnimationFrame from the host's vsync, for the screen on
|
|
418
|
+
// display only (Choreographer); one request covers every callback.
|
|
419
|
+
var frames = [];
|
|
420
|
+
var cancelledFrames = {};
|
|
421
|
+
var nextFrame = 0;
|
|
422
|
+
if (typeof globalThis.requestAnimationFrame !== 'function') {
|
|
423
|
+
globalThis.requestAnimationFrame = function(fn) {
|
|
424
|
+
if (typeof fn !== 'function') return 0;
|
|
425
|
+
var id = ++nextFrame;
|
|
426
|
+
frames.push({ id: id, fn: fn });
|
|
427
|
+
if (frames.length === 1) send(JSON.stringify({ type: 'FRAME', payload: {} }));
|
|
428
|
+
return id;
|
|
429
|
+
};
|
|
430
|
+
globalThis.cancelAnimationFrame = function(id) { cancelledFrames[id] = true; };
|
|
431
|
+
}
|
|
432
|
+
if (typeof globalThis.setTimeout !== 'function') {
|
|
433
|
+
globalThis.setTimeout = function(fn, ms) { return arm(fn, ms, Array.prototype.slice.call(arguments, 2), false); };
|
|
434
|
+
globalThis.setInterval = function(fn, ms) { return arm(fn, ms, Array.prototype.slice.call(arguments, 2), true); };
|
|
435
|
+
globalThis.clearTimeout = disarm;
|
|
436
|
+
globalThis.clearInterval = disarm;
|
|
437
|
+
}
|
|
438
|
+
function send(value) { if (typeof value !== 'string') value = JSON.stringify(value); $post }
|
|
439
|
+
function emit(message) {
|
|
440
|
+
if (forward) forward(typeof message === 'string' ? message : JSON.stringify(message));
|
|
441
|
+
else queued.push(message);
|
|
442
|
+
}
|
|
443
|
+
function deliver(raw) {
|
|
444
|
+
var message = raw;
|
|
445
|
+
if (typeof raw === 'string') {
|
|
446
|
+
try { message = JSON.parse(raw); } catch (error) { return emit(raw); }
|
|
447
|
+
}
|
|
448
|
+
if (message && message.type === 'SYNC_UPDATE') {
|
|
449
|
+
var payload = message.payload || {};
|
|
450
|
+
var bucket = sync[payload.store];
|
|
451
|
+
if (!bucket) return;
|
|
452
|
+
if (payload.clear) {
|
|
453
|
+
Object.keys(bucket).forEach(function(key) {
|
|
454
|
+
if (!(payload.keep || []).includes(key)) {
|
|
455
|
+
delete bucket[key];
|
|
456
|
+
if (payload.store === 'snapshots') emit({ type: 'SNAPSHOT_CHANGED', payload: { name: key } });
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (payload.value === null || payload.value === undefined) delete bucket[payload.key];
|
|
462
|
+
else bucket[payload.key] = payload.value;
|
|
463
|
+
if (payload.store === 'snapshots') emit({ type: 'SNAPSHOT_CHANGED', payload: { name: payload.key } });
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (message && message.type === 'TIMER_FIRE') {
|
|
467
|
+
var timer = timers[message.payload && message.payload.id];
|
|
468
|
+
if (!timer) return;
|
|
469
|
+
if (timer.every === null) delete timers[message.payload.id];
|
|
470
|
+
else send(JSON.stringify({ type: 'TIMER', payload: { id: message.payload.id, ms: timer.every } }));
|
|
471
|
+
try { timer.fn.apply(null, timer.args); } catch (error) { console.error('[timer] ' + (error && error.stack || error)); }
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (message && message.type === 'FRAME_FIRE') {
|
|
475
|
+
var due = frames;
|
|
476
|
+
frames = [];
|
|
477
|
+
var time = message.payload && message.payload.time || Date.now();
|
|
478
|
+
due.forEach(function(entry) {
|
|
479
|
+
if (cancelledFrames[entry.id]) { delete cancelledFrames[entry.id]; return; }
|
|
480
|
+
try { entry.fn(time); } catch (error) { console.error('[frame] ' + (error && error.stack || error)); }
|
|
481
|
+
});
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (message && message.type === 'SYNC_RESULT') {
|
|
485
|
+
var done = waiting[message.payload && message.payload.id];
|
|
486
|
+
if (done) {
|
|
487
|
+
delete waiting[message.payload.id];
|
|
488
|
+
done(message.payload.ok === true);
|
|
489
|
+
}
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
emit(raw);
|
|
493
|
+
}
|
|
494
|
+
function wait(type, payload) {
|
|
495
|
+
return new Promise(function(resolve, reject) {
|
|
496
|
+
var id = 'sync-' + (++nextWait);
|
|
497
|
+
waiting[id] = function(ok) { ok ? resolve() : reject(new Error('craft.' + type + ' could not be written')); };
|
|
498
|
+
payload.id = id;
|
|
499
|
+
send(JSON.stringify({ type: 'SYNC_WRITE', payload: payload }));
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
globalThis.__stxNativeBridge = {
|
|
503
|
+
$bridgeFields,
|
|
504
|
+
postMessage: function(value) { send(value); },
|
|
505
|
+
onMessage: function(callback) {
|
|
506
|
+
forward = callback;
|
|
507
|
+
var pending = queued;
|
|
508
|
+
queued = [];
|
|
509
|
+
pending.forEach(emit);
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
$receiveSetup
|
|
513
|
+
|
|
514
|
+
function parsed(json) {
|
|
515
|
+
if (json === null || json === undefined) return null;
|
|
516
|
+
try { return JSON.parse(json); } catch (error) { return null; }
|
|
517
|
+
}
|
|
518
|
+
var craft = globalThis.craft = globalThis.craft || {};
|
|
519
|
+
function keep(name, extras) {
|
|
520
|
+
var current = {};
|
|
521
|
+
function adopt(next) {
|
|
522
|
+
current = next && typeof next === 'object' ? next : {};
|
|
523
|
+
Object.keys(extras).forEach(function(key) {
|
|
524
|
+
if (!(key in current)) current[key] = extras[key];
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
adopt(craft[name]);
|
|
528
|
+
Object.defineProperty(craft, name, { configurable: true, enumerable: true, get: function() { return current; }, set: adopt });
|
|
529
|
+
}
|
|
530
|
+
keep('storage', {
|
|
531
|
+
getSync: function(key) { return parsed(sync.storage[String(key)]); },
|
|
532
|
+
setSync: function(key, value) {
|
|
533
|
+
var json = value === null || value === undefined ? null : JSON.stringify(value);
|
|
534
|
+
if (json === null) delete sync.storage[String(key)];
|
|
535
|
+
else sync.storage[String(key)] = json;
|
|
536
|
+
send(JSON.stringify({ type: 'SYNC_WRITE', payload: { store: 'storage', key: String(key), value: json } }));
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
keep('snapshots', {
|
|
540
|
+
get: function(name) { return parsed(sync.snapshots[String(name)]); },
|
|
541
|
+
set: function(name, value) {
|
|
542
|
+
var json = value === null || value === undefined ? null : JSON.stringify(value);
|
|
543
|
+
if (json === null) delete sync.snapshots[String(name)];
|
|
544
|
+
else sync.snapshots[String(name)] = json;
|
|
545
|
+
return wait('snapshots.set', { store: 'snapshots', key: String(name), value: json });
|
|
546
|
+
},
|
|
547
|
+
clear: function(keepNames) {
|
|
548
|
+
var names = Array.isArray(keepNames) ? keepNames.map(String) : [];
|
|
549
|
+
return wait('snapshots.clear', { store: 'snapshots', clear: true, keep: names });
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
keep('secureStorage', {
|
|
553
|
+
getSync: function(key) {
|
|
554
|
+
var value = sync.secure[String(key)];
|
|
555
|
+
return value === undefined ? null : value;
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
})();
|
|
559
|
+
""".trimIndent()
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* The bar above a screen (back, title, the screen's buttons) and the tab bar
|
|
564
|
+
* below it, in plain Android views. A screen sets its bar with
|
|
565
|
+
* `craft.navigation.setOptions` (title, largeTitle, hidden, rightButtons,
|
|
566
|
+
* tabBarHidden); the tabs come from the app's `tabs`.
|
|
567
|
+
*/
|
|
568
|
+
internal class CraftNativeChrome(context: Context, private val tabs: List<CraftNativeTab>) : LinearLayout(context) {
|
|
569
|
+
val content = FrameLayout(context)
|
|
570
|
+
private val bar = LinearLayout(context)
|
|
571
|
+
private val backButton = ImageView(context)
|
|
572
|
+
private val titleView = TextView(context)
|
|
573
|
+
private val largeTitleView = TextView(context)
|
|
574
|
+
private val buttons = LinearLayout(context)
|
|
575
|
+
private val tabBar = LinearLayout(context)
|
|
576
|
+
private val tabViews = mutableListOf<Pair<TextView, TextView>>()
|
|
577
|
+
private val density = resources.displayMetrics.density
|
|
578
|
+
private var selectedTab = 0
|
|
579
|
+
|
|
580
|
+
var onBack: (() -> Unit)? = null
|
|
581
|
+
var onButton: ((String) -> Unit)? = null
|
|
582
|
+
var onTab: ((Int) -> Unit)? = null
|
|
583
|
+
|
|
584
|
+
init {
|
|
585
|
+
orientation = VERTICAL
|
|
586
|
+
bar.orientation = HORIZONTAL
|
|
587
|
+
bar.gravity = Gravity.CENTER_VERTICAL
|
|
588
|
+
bar.setPadding(dp(4), 0, dp(8), 0)
|
|
589
|
+
backButton.setImageResource(androidx.appcompat.R.drawable.abc_ic_ab_back_material)
|
|
590
|
+
backButton.contentDescription = "Back"
|
|
591
|
+
backButton.setPadding(dp(12), dp(12), dp(12), dp(12))
|
|
592
|
+
backButton.background = rippleBackground()
|
|
593
|
+
backButton.setOnClickListener { onBack?.invoke() }
|
|
594
|
+
bar.addView(backButton, LayoutParams(dp(48), dp(48)))
|
|
595
|
+
titleView.textSize = 20f
|
|
596
|
+
titleView.typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
|
|
597
|
+
titleView.isSingleLine = true
|
|
598
|
+
titleView.ellipsize = TextUtils.TruncateAt.END
|
|
599
|
+
titleView.setPadding(dp(12), 0, dp(8), 0)
|
|
600
|
+
bar.addView(titleView, LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f))
|
|
601
|
+
buttons.orientation = HORIZONTAL
|
|
602
|
+
buttons.gravity = Gravity.CENTER_VERTICAL or Gravity.END
|
|
603
|
+
bar.addView(buttons, LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, dp(56)))
|
|
604
|
+
addView(bar, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(56)))
|
|
605
|
+
largeTitleView.textSize = 32f
|
|
606
|
+
largeTitleView.typeface = Typeface.create("sans-serif", Typeface.BOLD)
|
|
607
|
+
largeTitleView.isSingleLine = true
|
|
608
|
+
largeTitleView.ellipsize = TextUtils.TruncateAt.END
|
|
609
|
+
largeTitleView.setPadding(dp(16), 0, dp(16), dp(8))
|
|
610
|
+
addView(largeTitleView, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
|
611
|
+
addView(content, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f))
|
|
612
|
+
tabBar.orientation = HORIZONTAL
|
|
613
|
+
tabBar.gravity = Gravity.CENTER
|
|
614
|
+
tabs.forEachIndexed { index, tab ->
|
|
615
|
+
val cell = LinearLayout(context)
|
|
616
|
+
cell.orientation = VERTICAL
|
|
617
|
+
cell.gravity = Gravity.CENTER
|
|
618
|
+
cell.background = rippleBackground()
|
|
619
|
+
cell.contentDescription = tab.title
|
|
620
|
+
val icon = TextView(context)
|
|
621
|
+
icon.text = CraftNativeSymbols.glyph(tab.symbol)
|
|
622
|
+
icon.textSize = 22f
|
|
623
|
+
icon.minWidth = dp(26)
|
|
624
|
+
icon.minHeight = dp(26)
|
|
625
|
+
icon.gravity = Gravity.CENTER
|
|
626
|
+
icon.includeFontPadding = false
|
|
627
|
+
val label = TextView(context)
|
|
628
|
+
label.text = tab.title
|
|
629
|
+
label.textSize = 12f
|
|
630
|
+
label.gravity = Gravity.CENTER
|
|
631
|
+
label.isSingleLine = true
|
|
632
|
+
cell.addView(icon, LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
|
633
|
+
cell.addView(label, LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT))
|
|
634
|
+
cell.setOnClickListener { onTab?.invoke(index) }
|
|
635
|
+
tabBar.addView(cell, LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f))
|
|
636
|
+
tabViews.add(icon to label)
|
|
637
|
+
}
|
|
638
|
+
addView(tabBar, LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(64)))
|
|
639
|
+
tabBar.visibility = if (tabs.isEmpty()) View.GONE else View.VISIBLE
|
|
640
|
+
refreshColors()
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Draws `options` (a screen's `setOptions`) for a screen `depth` deep in its stack. */
|
|
644
|
+
fun apply(options: JSONObject, depth: Int, fallbackTitle: String?) {
|
|
645
|
+
val hidden = options.optBoolean("hidden", false)
|
|
646
|
+
val large = options.optBoolean("largeTitle", false) && depth == 0
|
|
647
|
+
val title = options.optString("title").takeIf { options.has("title") } ?: fallbackTitle.orEmpty()
|
|
648
|
+
bar.visibility = if (hidden) View.GONE else View.VISIBLE
|
|
649
|
+
backButton.visibility = if (depth > 0) View.VISIBLE else View.GONE
|
|
650
|
+
bar.setPadding(if (depth > 0) dp(4) else dp(4), 0, dp(8), 0)
|
|
651
|
+
titleView.text = if (large) "" else title
|
|
652
|
+
largeTitleView.text = title
|
|
653
|
+
largeTitleView.visibility = if (!hidden && large && title.isNotBlank()) View.VISIBLE else View.GONE
|
|
654
|
+
buttons.removeAllViews()
|
|
655
|
+
val list = options.optJSONArray("rightButtons") ?: JSONArray()
|
|
656
|
+
for (index in 0 until list.length()) {
|
|
657
|
+
val button = list.optJSONObject(index) ?: continue
|
|
658
|
+
buttons.addView(navButton(button))
|
|
659
|
+
}
|
|
660
|
+
val tabBarHidden = options.optBoolean("tabBarHidden", false) || depth > 0 && options.optBoolean("tabBarHiddenWhenPushed", false)
|
|
661
|
+
tabBar.visibility = if (tabs.isEmpty() || tabBarHidden) View.GONE else View.VISIBLE
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
fun selectTab(index: Int) {
|
|
665
|
+
selectedTab = index
|
|
666
|
+
refreshColors()
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Light or dark changed: the bars follow the screen's background. */
|
|
670
|
+
fun refreshColors() {
|
|
671
|
+
val background = ContextCompat.getColor(context, R.color.background)
|
|
672
|
+
val dark = Color.luminance(background) < 0.5
|
|
673
|
+
val text = if (dark) Color.WHITE else Color.BLACK
|
|
674
|
+
val muted = if (dark) Color.parseColor("#8E8E93") else Color.parseColor("#6C6C70")
|
|
675
|
+
val accent = accentColor(dark)
|
|
676
|
+
setBackgroundColor(background)
|
|
677
|
+
bar.setBackgroundColor(background)
|
|
678
|
+
titleView.setTextColor(text)
|
|
679
|
+
largeTitleView.setTextColor(text)
|
|
680
|
+
backButton.imageTintList = ColorStateList.valueOf(accent)
|
|
681
|
+
tabBar.setBackgroundColor(if (dark) Color.parseColor("#121212") else Color.parseColor("#F9F9F9"))
|
|
682
|
+
val divider = GradientDrawable().apply { setColor(if (dark) Color.parseColor("#262626") else Color.parseColor("#D8D8DC")) }
|
|
683
|
+
tabBar.foreground = null
|
|
684
|
+
tabBar.background = android.graphics.drawable.LayerDrawable(arrayOf(
|
|
685
|
+
divider,
|
|
686
|
+
GradientDrawable().apply { setColor(if (dark) Color.parseColor("#121212") else Color.parseColor("#F9F9F9")) },
|
|
687
|
+
)).apply { setLayerInset(1, 0, maxOf(1, (density / 2).toInt()), 0, 0) }
|
|
688
|
+
tabViews.forEachIndexed { index, (icon, label) ->
|
|
689
|
+
val color = if (index == selectedTab) accent else muted
|
|
690
|
+
icon.setTextColor(color)
|
|
691
|
+
label.setTextColor(color)
|
|
692
|
+
val vector = CraftNativeIcons.drawable(context, null, tabs[index].symbol, color)
|
|
693
|
+
icon.foreground = vector
|
|
694
|
+
if (vector != null) icon.text = ""
|
|
695
|
+
}
|
|
696
|
+
for (index in 0 until buttons.childCount) (buttons.getChildAt(index) as? TextView)?.setTextColor(accent)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private fun navButton(button: JSONObject): View {
|
|
700
|
+
val id = button.optString("id")
|
|
701
|
+
val title = button.optString("title")
|
|
702
|
+
val symbol = button.optString("symbol")
|
|
703
|
+
val view = TextView(context)
|
|
704
|
+
val dark = Color.luminance(ContextCompat.getColor(context, R.color.background)) < 0.5
|
|
705
|
+
val tint = button.optString("color").takeIf { it.isNotBlank() }?.let { runCatching { craftNativeColor(it) }.getOrNull() } ?: accentColor(dark)
|
|
706
|
+
val vector = if (symbol.isNotBlank()) CraftNativeIcons.drawable(context, null, symbol, tint) else null
|
|
707
|
+
view.text = if (vector != null) "" else if (symbol.isNotBlank()) CraftNativeSymbols.glyph(symbol) else title
|
|
708
|
+
view.textSize = if (symbol.isNotBlank()) 22f else 16f
|
|
709
|
+
if (vector != null) {
|
|
710
|
+
view.foreground = android.graphics.drawable.InsetDrawable(vector, dp(10), dp(15), dp(10), dp(15))
|
|
711
|
+
view.minWidth = dp(46)
|
|
712
|
+
}
|
|
713
|
+
view.gravity = Gravity.CENTER
|
|
714
|
+
view.minWidth = dp(44)
|
|
715
|
+
view.setPadding(dp(10), 0, dp(10), 0)
|
|
716
|
+
view.background = rippleBackground()
|
|
717
|
+
view.contentDescription = button.optString("accessibilityLabel").ifBlank { title.ifBlank { symbol } }
|
|
718
|
+
view.setTextColor(tint)
|
|
719
|
+
val badge = button.optInt("badge", 0)
|
|
720
|
+
if (badge > 0) {
|
|
721
|
+
view.text = "${view.text}${if (button.optString("badgeStyle") == "dot") " •" else " $badge"}"
|
|
722
|
+
}
|
|
723
|
+
view.setOnClickListener { onButton?.invoke(id) }
|
|
724
|
+
view.layoutParams = LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
|
725
|
+
return view
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
private fun accentColor(dark: Boolean): Int = if (dark) Color.parseColor("#0A84FF") else Color.parseColor("#007AFF")
|
|
729
|
+
|
|
730
|
+
private fun rippleBackground(): android.graphics.drawable.Drawable? {
|
|
731
|
+
val value = TypedValue()
|
|
732
|
+
return if (context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless, value, true)) {
|
|
733
|
+
ContextCompat.getDrawable(context, value.resourceId)
|
|
734
|
+
} else null
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
private fun dp(value: Int) = (value * density).toInt()
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* SF Symbol names (what stx resolves an icon to, for iOS) as glyphs Android's
|
|
742
|
+
* own fonts draw, for the bars. A name with no glyph gets a neutral dot.
|
|
743
|
+
*/
|
|
744
|
+
internal object CraftNativeSymbols {
|
|
745
|
+
fun glyph(symbol: String): String {
|
|
746
|
+
val base = symbol.removeSuffix(".fill").removeSuffix(".circle")
|
|
747
|
+
return when (symbol) {
|
|
748
|
+
"plus", "plus.circle", "plus.circle.fill" -> "+"
|
|
749
|
+
"xmark", "xmark.circle", "xmark.circle.fill" -> "✕"
|
|
750
|
+
"checkmark", "checkmark.circle", "checkmark.circle.fill" -> "✓"
|
|
751
|
+
"ellipsis", "ellipsis.circle" -> "⋯"
|
|
752
|
+
"square.and.arrow.up" -> "⇪"
|
|
753
|
+
"magnifyingglass" -> "⌕"
|
|
754
|
+
"gear", "gearshape", "gearshape.fill" -> "⚙"
|
|
755
|
+
"bell", "bell.fill", "bell.badge" -> "🔔︎"
|
|
756
|
+
"person", "person.fill", "person.crop.circle", "person.crop.circle.fill", "person.circle" -> "👤︎"
|
|
757
|
+
"person.2", "person.2.fill" -> "👥︎"
|
|
758
|
+
"message", "message.fill", "bubble.left", "bubble.left.and.bubble.right", "bubble.left.fill" -> "💬︎"
|
|
759
|
+
"paperplane", "paperplane.fill" -> "➤"
|
|
760
|
+
"sun.max", "sun.max.fill" -> "☀︎"
|
|
761
|
+
"moon", "moon.fill", "moon.zzz" -> "☾"
|
|
762
|
+
"calendar" -> "▦"
|
|
763
|
+
"chart.line.uptrend.xyaxis", "chart.xyaxis.line", "chart.bar", "chart.bar.fill" -> "📈︎"
|
|
764
|
+
"heart", "heart.text.square" -> "♡"
|
|
765
|
+
"heart.fill" -> "♥"
|
|
766
|
+
"bag", "bag.fill", "cart", "cart.fill" -> "🛍︎"
|
|
767
|
+
"house", "house.fill" -> "⌂"
|
|
768
|
+
"pencil", "square.and.pencil" -> "✎"
|
|
769
|
+
"trash", "trash.fill" -> "🗑︎"
|
|
770
|
+
"arrow.clockwise" -> "↻"
|
|
771
|
+
"slider.horizontal.3" -> "☰"
|
|
772
|
+
"line.3.horizontal", "list.bullet" -> "☰"
|
|
773
|
+
"play", "play.fill" -> "▶"
|
|
774
|
+
"pause", "pause.fill" -> "⏸︎"
|
|
775
|
+
"star", "star.fill" -> "★"
|
|
776
|
+
"qrcode", "qrcode.viewfinder" -> "▣"
|
|
777
|
+
"info.circle" -> "ⓘ"
|
|
778
|
+
else -> when (base) {
|
|
779
|
+
"chevron.left" -> "‹"
|
|
780
|
+
"chevron.right" -> "›"
|
|
781
|
+
else -> "•"
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Video and audio kept on the phone for offline (`craft.media`), the iOS
|
|
789
|
+
* host's CraftNativeMedia: `files/craft-media/<sha256 of the address>.<ext>`.
|
|
790
|
+
* A native Video given an address with a copy here plays the copy.
|
|
791
|
+
*/
|
|
792
|
+
internal object CraftNativeMedia {
|
|
793
|
+
private val inFlight = HashMap<String, MutableList<(CraftNativeCapabilityResult) -> Unit>>()
|
|
794
|
+
private val executor = Executors.newFixedThreadPool(2)
|
|
795
|
+
private val main = Handler(Looper.getMainLooper())
|
|
796
|
+
|
|
797
|
+
private fun root(context: Context) = File(context.applicationContext.filesDir, "craft-media")
|
|
798
|
+
|
|
799
|
+
fun file(context: Context, address: String): File {
|
|
800
|
+
val digest = java.security.MessageDigest.getInstance("SHA-256").digest(address.toByteArray()).joinToString("") { "%02x".format(it) }
|
|
801
|
+
val ext = runCatching { Uri.parse(address).lastPathSegment?.substringAfterLast('.', "")?.lowercase() }.getOrNull().orEmpty()
|
|
802
|
+
val safe = if (Regex("^[a-z0-9]{1,5}$").matches(ext)) ext else "bin"
|
|
803
|
+
return File(root(context), "$digest.$safe")
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
fun local(context: Context, address: String): File? =
|
|
807
|
+
address.takeIf { it.isNotBlank() }?.let { file(context, it) }?.takeIf(File::exists)
|
|
808
|
+
|
|
809
|
+
private fun describe(address: String, file: File) = JSONObject().put("ok", true).put("url", address).put("path", file.absolutePath).put("size", file.length())
|
|
810
|
+
|
|
811
|
+
fun keep(context: Context, address: String, headers: JSONObject, done: (CraftNativeCapabilityResult) -> Unit) {
|
|
812
|
+
val uri = runCatching { Uri.parse(address) }.getOrNull()
|
|
813
|
+
if (uri == null || uri.scheme?.lowercase() !in setOf("http", "https") || uri.host.isNullOrBlank()) {
|
|
814
|
+
return done(CraftNativeCapabilityResult(code = "INVALID_ARGUMENT", message = "media.keep needs an absolute http or https URL"))
|
|
815
|
+
}
|
|
816
|
+
local(context, address)?.let { return done(CraftNativeCapabilityResult(data = describe(address, it))) }
|
|
817
|
+
synchronized(inFlight) {
|
|
818
|
+
inFlight[address]?.let { waiting ->
|
|
819
|
+
waiting.add(done)
|
|
820
|
+
return
|
|
821
|
+
}
|
|
822
|
+
inFlight[address] = mutableListOf(done)
|
|
823
|
+
}
|
|
824
|
+
executor.execute {
|
|
825
|
+
val result = runCatching {
|
|
826
|
+
val connection = java.net.URL(address).openConnection() as java.net.HttpURLConnection
|
|
827
|
+
connection.connectTimeout = 30_000
|
|
828
|
+
connection.readTimeout = 60_000
|
|
829
|
+
headers.keys().forEach { name -> connection.setRequestProperty(name, headers.optString(name)) }
|
|
830
|
+
try {
|
|
831
|
+
val status = connection.responseCode
|
|
832
|
+
if (status !in 200..299) return@runCatching CraftNativeCapabilityResult(data = JSONObject().put("ok", false).put("url", address).put("status", status))
|
|
833
|
+
val target = file(context, address)
|
|
834
|
+
target.parentFile?.mkdirs()
|
|
835
|
+
// Written aside and moved into place, so a half-written file is never played.
|
|
836
|
+
val staging = File(target.parentFile, "${java.util.UUID.randomUUID()}.part")
|
|
837
|
+
connection.inputStream.use { input -> staging.outputStream().use { input.copyTo(it) } }
|
|
838
|
+
if (!staging.renameTo(target)) {
|
|
839
|
+
staging.delete()
|
|
840
|
+
return@runCatching CraftNativeCapabilityResult(code = "IO_ERROR", message = "media.keep could not store the file")
|
|
841
|
+
}
|
|
842
|
+
CraftNativeCapabilityResult(data = describe(address, target))
|
|
843
|
+
} finally {
|
|
844
|
+
connection.disconnect()
|
|
845
|
+
}
|
|
846
|
+
}.getOrElse { error ->
|
|
847
|
+
CraftNativeCapabilityResult(code = if (error is java.net.SocketTimeoutException) "TIMEOUT" else "NETWORK_ERROR", message = error.message ?: "media.keep failed")
|
|
848
|
+
}
|
|
849
|
+
val waiting = synchronized(inFlight) { inFlight.remove(address) ?: mutableListOf() }
|
|
850
|
+
main.post { waiting.forEach { it(result) } }
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** Removes every copy whose address is not in `keep`; answers how many. */
|
|
855
|
+
fun prune(context: Context, keep: List<String>): Int {
|
|
856
|
+
val wanted = keep.map { file(context, it).name }.toSet()
|
|
857
|
+
var removed = 0
|
|
858
|
+
root(context).listFiles()?.forEach { file -> if (file.name !in wanted && file.delete()) removed++ }
|
|
859
|
+
return removed
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
fun usage(context: Context): JSONObject {
|
|
863
|
+
val kept = root(context).listFiles()?.filter { !it.name.endsWith(".part") }.orEmpty()
|
|
864
|
+
return JSONObject().put("files", kept.size).put("bytes", kept.sumOf { it.length() })
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Messages from an isolate on the console bridge (a WebView without
|
|
870
|
+
* JavaScriptSandbox's message ports): the console cuts a message at about
|
|
871
|
+
* 32 KB, which dropped every large render, mutation and snapshot. The
|
|
872
|
+
* prelude sends a long one in numbered pieces (`\u0001<id>:<index>:<count>:`)
|
|
873
|
+
* and this puts them back together.
|
|
874
|
+
*/
|
|
875
|
+
internal class CraftNativeChunks {
|
|
876
|
+
private val pending = HashMap<String, Array<String?>>()
|
|
877
|
+
|
|
878
|
+
/** The whole message once its last piece arrives, the message itself when it came whole, else null. */
|
|
879
|
+
fun accept(body: String): String? {
|
|
880
|
+
if (!body.startsWith("\u0001")) return body
|
|
881
|
+
val header = body.substring(1).split(':', limit = 4)
|
|
882
|
+
if (header.size < 4) return null
|
|
883
|
+
val id = header[0]
|
|
884
|
+
val index = header[1].toIntOrNull() ?: return null
|
|
885
|
+
val count = header[2].toIntOrNull()?.takeIf { it in 1..100_000 } ?: return null
|
|
886
|
+
val pieces = pending.getOrPut(id) { arrayOfNulls(count) }
|
|
887
|
+
if (index !in pieces.indices) return null
|
|
888
|
+
pieces[index] = header[3]
|
|
889
|
+
if (pieces.any { it == null }) return null
|
|
890
|
+
pending.remove(id)
|
|
891
|
+
return pieces.joinToString("")
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
companion object {
|
|
895
|
+
/** The console bridge's `post`: long messages in pieces the console keeps whole. */
|
|
896
|
+
fun consolePost(prefix: String): String = """
|
|
897
|
+
var limit = 16000;
|
|
898
|
+
if (value.length <= limit) console.log(${JSONObject.quote(prefix)} + value);
|
|
899
|
+
else {
|
|
900
|
+
globalThis.__craftChunk = (globalThis.__craftChunk || 0) + 1;
|
|
901
|
+
var count = Math.ceil(value.length / limit);
|
|
902
|
+
for (var piece = 0; piece < count; piece++) {
|
|
903
|
+
console.log(${JSONObject.quote(prefix)} + '\u0001' + globalThis.__craftChunk + ':' + piece + ':' + count + ':' + value.slice(piece * limit, (piece + 1) * limit));
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
""".trimIndent()
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* The app's icons as vectors (`assets/craft-icons.json`): what an `<Icon>`
|
|
912
|
+
* draws on iOS as an SF Symbol, drawn on Android from the same icon the web
|
|
913
|
+
* shows (the app's Iconify set, Lucide for HQ), keyed by its own name and by
|
|
914
|
+
* the SF Symbol stx maps it to. Each entry is an SVG body on a 24-point
|
|
915
|
+
* view box, stroked the way Lucide is (2 points, round caps and joins). An
|
|
916
|
+
* icon the table lacks falls back to the glyph Android's fonts have.
|
|
917
|
+
*/
|
|
918
|
+
internal object CraftNativeIcons {
|
|
919
|
+
private data class Shape(val path: android.graphics.Path, val filled: Boolean)
|
|
920
|
+
private class Icon(val shapes: List<Shape>, val viewBox: Float, val stroke: Float)
|
|
921
|
+
|
|
922
|
+
@Volatile private var table: Map<String, Icon>? = null
|
|
923
|
+
|
|
924
|
+
private fun load(context: Context): Map<String, Icon> = table ?: synchronized(this) {
|
|
925
|
+
table ?: runCatching {
|
|
926
|
+
val json = JSONObject(context.assets.open("craft-icons.json").bufferedReader().use { it.readText() })
|
|
927
|
+
val size = json.optDouble("viewBox", 24.0).toFloat()
|
|
928
|
+
val stroke = json.optDouble("strokeWidth", 2.0).toFloat()
|
|
929
|
+
val icons = json.optJSONObject("icons") ?: JSONObject()
|
|
930
|
+
val parsed = HashMap<String, Icon>()
|
|
931
|
+
icons.keys().forEach { name -> parse(icons.optString(name))?.let { parsed[name] = Icon(it, size, stroke) } }
|
|
932
|
+
parsed as Map<String, Icon>
|
|
933
|
+
}.getOrElse { emptyMap() }.also { table = it }
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
private fun attributes(element: String): Map<String, String> =
|
|
937
|
+
Regex("""([a-zA-Z-]+)="([^"]*)"""").findAll(element).associate { it.groupValues[1] to it.groupValues[2] }
|
|
938
|
+
|
|
939
|
+
private fun number(value: String?): Float = value?.trim()?.toFloatOrNull() ?: 0f
|
|
940
|
+
|
|
941
|
+
private fun parse(body: String): List<Shape>? = runCatching {
|
|
942
|
+
Regex("""<(path|circle|rect|line|polyline|polygon|ellipse)\b([^>]*)/?>""").findAll(body).mapNotNull { match ->
|
|
943
|
+
val tag = match.groupValues[1]
|
|
944
|
+
val a = attributes(match.groupValues[2])
|
|
945
|
+
val filled = a["fill"]?.let { it != "none" } == true && a["stroke"] == null
|
|
946
|
+
val path = when (tag) {
|
|
947
|
+
"path" -> androidx.core.graphics.PathParser.createPathFromPathData(a["d"] ?: return@mapNotNull null)
|
|
948
|
+
"circle" -> android.graphics.Path().apply { addCircle(number(a["cx"]), number(a["cy"]), number(a["r"]), android.graphics.Path.Direction.CW) }
|
|
949
|
+
"ellipse" -> android.graphics.Path().apply {
|
|
950
|
+
val cx = number(a["cx"]); val cy = number(a["cy"]); val rx = number(a["rx"]); val ry = number(a["ry"])
|
|
951
|
+
addOval(android.graphics.RectF(cx - rx, cy - ry, cx + rx, cy + ry), android.graphics.Path.Direction.CW)
|
|
952
|
+
}
|
|
953
|
+
"rect" -> android.graphics.Path().apply {
|
|
954
|
+
val x = number(a["x"]); val y = number(a["y"]); val w = number(a["width"]); val h = number(a["height"])
|
|
955
|
+
val rx = number(a["rx"] ?: a["ry"]); val ry = number(a["ry"] ?: a["rx"])
|
|
956
|
+
addRoundRect(android.graphics.RectF(x, y, x + w, y + h), rx, ry, android.graphics.Path.Direction.CW)
|
|
957
|
+
}
|
|
958
|
+
"line" -> android.graphics.Path().apply {
|
|
959
|
+
moveTo(number(a["x1"]), number(a["y1"]))
|
|
960
|
+
lineTo(number(a["x2"]), number(a["y2"]))
|
|
961
|
+
}
|
|
962
|
+
else -> android.graphics.Path().apply {
|
|
963
|
+
val points = (a["points"] ?: "").trim().split(Regex("[\\s,]+")).mapNotNull { it.toFloatOrNull() }
|
|
964
|
+
for (index in 0 until points.size / 2) {
|
|
965
|
+
if (index == 0) moveTo(points[0], points[1]) else lineTo(points[index * 2], points[index * 2 + 1])
|
|
966
|
+
}
|
|
967
|
+
if (tag == "polygon") close()
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
Shape(path, filled)
|
|
971
|
+
}.toList().takeIf { it.isNotEmpty() }
|
|
972
|
+
}.getOrNull()
|
|
973
|
+
|
|
974
|
+
/** The icon for `name` (the Iconify name) or `symbol` (the SF Symbol), in `color`; null when the app has no vector for it. */
|
|
975
|
+
fun drawable(context: Context, name: String?, symbol: String?, color: Int): android.graphics.drawable.Drawable? {
|
|
976
|
+
val icons = load(context)
|
|
977
|
+
val bare = name?.removePrefix("i-lucide-")?.substringAfter("lucide:")
|
|
978
|
+
val icon = (bare?.let(icons::get)) ?: symbol?.let(icons::get) ?: return null
|
|
979
|
+
return Vector(icon, color)
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
private class Vector(private val icon: Icon, color: Int) : android.graphics.drawable.Drawable() {
|
|
983
|
+
private val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
|
|
984
|
+
this.color = color
|
|
985
|
+
strokeCap = android.graphics.Paint.Cap.ROUND
|
|
986
|
+
strokeJoin = android.graphics.Paint.Join.ROUND
|
|
987
|
+
}
|
|
988
|
+
private val matrix = android.graphics.Matrix()
|
|
989
|
+
private val scaled = android.graphics.Path()
|
|
990
|
+
|
|
991
|
+
override fun draw(canvas: android.graphics.Canvas) {
|
|
992
|
+
val box = bounds
|
|
993
|
+
if (box.isEmpty) return
|
|
994
|
+
val scale = minOf(box.width(), box.height()) / icon.viewBox
|
|
995
|
+
val left = box.left + (box.width() - icon.viewBox * scale) / 2f
|
|
996
|
+
val top = box.top + (box.height() - icon.viewBox * scale) / 2f
|
|
997
|
+
matrix.setScale(scale, scale)
|
|
998
|
+
matrix.postTranslate(left, top)
|
|
999
|
+
for (shape in icon.shapes) {
|
|
1000
|
+
scaled.reset()
|
|
1001
|
+
shape.path.transform(matrix, scaled)
|
|
1002
|
+
paint.style = if (shape.filled) android.graphics.Paint.Style.FILL else android.graphics.Paint.Style.STROKE
|
|
1003
|
+
paint.strokeWidth = icon.stroke * scale
|
|
1004
|
+
canvas.drawPath(scaled, paint)
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
override fun setAlpha(alpha: Int) { paint.alpha = alpha; invalidateSelf() }
|
|
1009
|
+
override fun setColorFilter(colorFilter: android.graphics.ColorFilter?) { paint.colorFilter = colorFilter; invalidateSelf() }
|
|
1010
|
+
@Deprecated("Drawable API")
|
|
1011
|
+
override fun getOpacity(): Int = android.graphics.PixelFormat.TRANSLUCENT
|
|
1012
|
+
}
|
|
1013
|
+
}
|