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.
@@ -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
+ }