craft-native 0.0.113 → 0.0.118

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.
@@ -3,7 +3,11 @@ package {{PACKAGE_NAME}}
3
3
  import android.content.ClipData
4
4
  import android.content.ClipboardManager
5
5
  import android.content.Context
6
+ import android.graphics.BitmapFactory
6
7
  import android.graphics.Color
8
+ import android.graphics.Paint
9
+ import android.graphics.Typeface
10
+ import android.graphics.drawable.GradientDrawable
7
11
  import android.os.Build
8
12
  import android.os.Bundle
9
13
  import android.os.VibrationEffect
@@ -11,15 +15,24 @@ import android.os.Vibrator
11
15
  import android.text.Editable
12
16
  import android.text.TextWatcher
13
17
  import android.util.Log
18
+ import android.util.Base64
19
+ import android.util.TypedValue
20
+ import android.view.Gravity
14
21
  import android.view.View
15
22
  import android.view.ViewGroup
16
23
  import android.widget.Button
17
24
  import android.widget.EditText
18
25
  import android.widget.FrameLayout
26
+ import android.widget.HorizontalScrollView
27
+ import android.widget.ImageView
19
28
  import android.widget.LinearLayout
29
+ import android.widget.ScrollView
20
30
  import android.widget.TextView
21
31
  import androidx.appcompat.app.AppCompatActivity
22
32
  import androidx.core.content.ContextCompat
33
+ import androidx.core.view.AccessibilityDelegateCompat
34
+ import androidx.core.view.ViewCompat
35
+ import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
23
36
  import androidx.javascriptengine.JavaScriptIsolate
24
37
  import androidx.javascriptengine.JavaScriptConsoleCallback
25
38
  import androidx.javascriptengine.JavaScriptSandbox
@@ -28,8 +41,36 @@ import androidx.javascriptengine.MessagePort
28
41
  import androidx.javascriptengine.MessagePortClient
29
42
  import org.json.JSONArray
30
43
  import org.json.JSONObject
44
+ import java.net.URL
45
+ import java.util.concurrent.Executors
46
+ import java.util.concurrent.Future
31
47
  import java.util.UUID
32
48
 
49
+ private class CraftNativeScrollView(context: Context) : FrameLayout(context) {
50
+ val content = LinearLayout(context)
51
+ private val vertical = ScrollView(context)
52
+ private val horizontal = HorizontalScrollView(context)
53
+ private var horizontalMode: Boolean? = null
54
+
55
+ init { setHorizontal(false) }
56
+
57
+ fun setHorizontal(value: Boolean) {
58
+ if (horizontalMode == value) return
59
+ horizontalMode = value
60
+ (content.parent as? ViewGroup)?.removeView(content)
61
+ removeAllViews()
62
+ val scroller: ViewGroup = if (value) horizontal else vertical
63
+ scroller.removeAllViews()
64
+ content.orientation = if (value) LinearLayout.HORIZONTAL else LinearLayout.VERTICAL
65
+ scroller.addView(content, if (value)
66
+ ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.MATCH_PARENT)
67
+ else ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT))
68
+ addView(scroller, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
69
+ }
70
+ }
71
+
72
+ private class CraftNativeFlexSpacer(context: Context) : View(context)
73
+
33
74
  /** WebView-free host for a compiled stx-native route bundle. */
34
75
  class MainActivity : AppCompatActivity() {
35
76
  private class Screen(
@@ -42,6 +83,10 @@ class MainActivity : AppCompatActivity() {
42
83
  val drafts = mutableMapOf<String, String>()
43
84
  val controls = mutableMapOf<String, View>()
44
85
  val handlers = mutableMapOf<View, String>()
86
+ val flatListRows = mutableMapOf<CraftNativeFlatList, MutableMap<String, MutableMap<String, View>>>()
87
+ val flatListOwners = mutableMapOf<String, String>()
88
+ val flatListOwnerByView = mutableMapOf<CraftNativeFlatList, String>()
89
+ val mutations = CraftNativeMutationDocument()
45
90
  var active = true
46
91
 
47
92
  fun close() {
@@ -58,6 +103,10 @@ class MainActivity : AppCompatActivity() {
58
103
  private var hapticsEnabled = false
59
104
  private var defaultTextColor = Color.WHITE
60
105
  private var useMessagePorts = false
106
+ private val imageExecutor = Executors.newFixedThreadPool(2)
107
+ private val imageSources = mutableMapOf<ImageView, String>()
108
+ private val imageJobs = mutableMapOf<ImageView, Future<*>>()
109
+ private val imageErrors = mutableMapOf<ImageView, String>()
61
110
 
62
111
  override fun onCreate(savedInstanceState: Bundle?) {
63
112
  super.onCreate(savedInstanceState)
@@ -145,6 +194,7 @@ class MainActivity : AppCompatActivity() {
145
194
  const port = await android.getNamedPort('craft');
146
195
  $routePrelude
147
196
  globalThis.__stxNativeBridge = {
197
+ mutationProtocolVersion: 1,
148
198
  postMessage: function(value) { port.postMessage(value); },
149
199
  onMessage: function(callback) { port.onmessage = function(event) { callback(event.data); }; }
150
200
  };
@@ -152,6 +202,7 @@ class MainActivity : AppCompatActivity() {
152
202
  """.trimIndent() else """
153
203
  $routePrelude
154
204
  globalThis.__stxNativeBridge = {
205
+ mutationProtocolVersion: 1,
155
206
  postMessage: function(value) { console.log(${JSONObject.quote(prefix)} + value); },
156
207
  onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
157
208
  };
@@ -186,11 +237,34 @@ class MainActivity : AppCompatActivity() {
186
237
  val payload = message.optJSONObject("payload") ?: JSONObject()
187
238
  when (message.optString("type")) {
188
239
  "RENDER" -> payload.optJSONObject("document")?.let { render(screen, it) }
240
+ "MUTATE" -> try {
241
+ val result = applyMutation(screen, payload)
242
+ send(screen, "MUTATION_ACK", JSONObject()
243
+ .put("batchId", result.batchId)
244
+ .put("revision", result.revision)
245
+ .put("version", CRAFT_NATIVE_MUTATION_PROTOCOL_VERSION))
246
+ }
247
+ catch (failure: CraftNativeMutationFailure) {
248
+ val error = JSONObject()
249
+ .put("batchId", payload.optString("batchId"))
250
+ .put("code", failure.code)
251
+ .put("message", failure.message)
252
+ .put("version", CRAFT_NATIVE_MUTATION_PROTOCOL_VERSION)
253
+ failure.operationIndex?.let { error.put("operationIndex", it) }
254
+ send(screen, "MUTATION_ERROR", error)
255
+ }
256
+ catch (_: Exception) {
257
+ send(screen, "MUTATION_ERROR", JSONObject()
258
+ .put("batchId", payload.optString("batchId"))
259
+ .put("code", "INVALID_BATCH")
260
+ .put("message", "mutation batch could not be applied")
261
+ .put("version", CRAFT_NATIVE_MUTATION_PROTOCOL_VERSION))
262
+ }
189
263
  "NAVIGATE", "NAVIGATE_REPLACE" -> {
190
264
  if (screens.lastOrNull() !== screen) return
191
265
  val route = payload.optString("screen").takeIf { it.isNotBlank() } ?: return
192
266
  if (message.optString("type") == "NAVIGATE_REPLACE") {
193
- screens.removeAt(screens.lastIndex).close()
267
+ closeScreen(screens.removeAt(screens.lastIndex))
194
268
  }
195
269
  openScreen(route, payload.optJSONObject("params") ?: JSONObject())
196
270
  }
@@ -200,6 +274,30 @@ class MainActivity : AppCompatActivity() {
200
274
  }
201
275
 
202
276
  private fun render(screen: Screen, document: JSONObject) {
277
+ screen.mutations.replace(document)
278
+ renderCommitted(screen, document)
279
+ }
280
+
281
+ private fun applyMutation(screen: Screen, payload: JSONObject): CraftNativeMutationResult {
282
+ val result = screen.mutations.apply(payload)
283
+ if (!result.requiresFullRender && applyTargetedUpdates(screen, result.affectedNodeIds)) return result
284
+ result.document?.let { renderCommitted(screen, it) } ?: clearRenderedTree(screen)
285
+ return result
286
+ }
287
+
288
+ private fun applyTargetedUpdates(screen: Screen, ids: List<String>): Boolean {
289
+ val applied = mutableSetOf<String>()
290
+ for (id in ids) {
291
+ val target = screen.flatListOwners[id] ?: id
292
+ if (!applied.add(target)) continue
293
+ val document = screen.mutations.node(target) ?: return false
294
+ if (!screen.controls.containsKey("id:$target")) return false
295
+ makeView(screen, document, target, screen.controls)
296
+ }
297
+ return true
298
+ }
299
+
300
+ private fun renderCommitted(screen: Screen, document: JSONObject) {
203
301
  // Keep live controls (especially the focused EditText) across renders.
204
302
  val next = mutableMapOf<String, View>()
205
303
  val tree = makeView(screen, document, "root", next)
@@ -208,26 +306,44 @@ class MainActivity : AppCompatActivity() {
208
306
  screen.view.removeAllViews()
209
307
  screen.view.addView(tree)
210
308
  }
309
+ releaseViewState(screen, screen.controls.values.filter { it !in next.values })
211
310
  screen.controls.clear()
212
311
  screen.controls.putAll(next)
213
- screen.handlers.keys.retainAll(next.values.toSet())
312
+ screen.handlers.keys.retainAll(retainedViews(screen))
214
313
  }
215
314
 
216
- private fun makeView(screen: Screen, node: JSONObject, path: String, next: MutableMap<String, View>): View {
315
+ private fun clearRenderedTree(screen: Screen) {
316
+ releaseViewState(screen, retainedViews(screen).toList())
317
+ screen.view.removeAllViews()
318
+ screen.controls.clear()
319
+ screen.handlers.clear()
320
+ screen.flatListRows.clear()
321
+ screen.flatListOwners.clear()
322
+ screen.flatListOwnerByView.clear()
323
+ }
324
+
325
+ private fun makeView(
326
+ screen: Screen,
327
+ node: JSONObject,
328
+ path: String,
329
+ next: MutableMap<String, View>,
330
+ previousControls: Map<String, View> = screen.controls,
331
+ ): View {
217
332
  val type = node.optString("type", "View")
218
333
  val props = node.optJSONObject("props") ?: JSONObject()
219
334
  val style = node.optJSONObject("style") ?: JSONObject()
220
335
  val events = node.optJSONObject("events") ?: JSONObject()
221
336
  val children = node.optJSONArray("children") ?: JSONArray()
337
+ val protocolId = node.optString("id").takeIf { it.isNotBlank() }
222
338
  val explicitKey = listOf(node.optString("key"), props.optString("key"), props.optString("testID"))
223
339
  .firstOrNull { it.isNotBlank() }
224
- val identity = explicitKey?.let { "${path.substringBeforeLast('.', "")}/$it" } ?: path
225
- val previous = screen.controls[identity]
340
+ val identity = protocolId?.let { "id:$it" }
341
+ ?: explicitKey?.let { "${path.substringBeforeLast('.', "")}/$it" }
342
+ ?: path
343
+ val previous = previousControls[identity]
226
344
  val control = when (type) {
227
345
  "Text" -> ((previous as? TextView)?.takeUnless { it is Button || it is EditText } ?: TextView(this)).apply {
228
- text = childText(children)
229
- setTextColor(color(style.optString("color"), defaultTextColor))
230
- if (style.has("fontSize")) textSize = style.optDouble("fontSize").toFloat()
346
+ configureText(this, childText(children), style)
231
347
  }
232
348
  "Button" -> (previous as? Button ?: Button(this)).apply {
233
349
  text = props.optString("title").ifBlank { childText(children) }
@@ -257,46 +373,63 @@ class MainActivity : AppCompatActivity() {
257
373
  override fun afterTextChanged(s: Editable?) {}
258
374
  })
259
375
  }
260
- else -> (previous as? LinearLayout ?: LinearLayout(this)).apply {
261
- orientation = if (style.optString("flexDirection") == "row")
262
- LinearLayout.HORIZONTAL else LinearLayout.VERTICAL
263
- val padding = style.optDouble("padding", 0.0)
264
- setPadding(
265
- dp(style.optDouble("paddingLeft", padding)),
266
- dp(style.optDouble("paddingTop", padding)),
267
- dp(style.optDouble("paddingRight", padding)),
268
- dp(style.optDouble("paddingBottom", padding))
269
- )
270
- val desired = mutableListOf<View>()
271
- for (index in 0 until children.length()) {
272
- val child = children.opt(index)
273
- val childView = when (child) {
274
- is JSONObject -> makeView(screen, child, "$path.$index", next)
275
- is String -> TextView(this@MainActivity).apply {
276
- text = child
277
- setTextColor(defaultTextColor)
278
- }
279
- else -> null
280
- }
281
- if (childView != null) desired.add(childView)
376
+ "Image" -> (previous as? ImageView ?: ImageView(this)).apply {
377
+ scaleType = when (style.optString("resizeMode").ifBlank { props.optString("resizeMode") }) {
378
+ "cover" -> ImageView.ScaleType.CENTER_CROP
379
+ "stretch" -> ImageView.ScaleType.FIT_XY
380
+ "center" -> ImageView.ScaleType.CENTER
381
+ else -> ImageView.ScaleType.CENTER_INSIDE
282
382
  }
283
- val desiredSet = desired.toSet()
284
- for ((index, childView) in desired.withIndex()) {
285
- if (getChildAt(index) === childView) continue
286
- if (index < childCount && getChildAt(index) !in desiredSet) removeViewAt(index)
287
- (childView.parent as? ViewGroup)?.removeView(childView)
288
- addView(childView, index)
383
+ adjustViewBounds = false
384
+ updateImage(this, props.opt("source"))
385
+ }
386
+ "ScrollView" -> (previous as? CraftNativeScrollView ?: CraftNativeScrollView(this)).apply {
387
+ val horizontal = props.optBoolean("horizontal", false) || style.optString("flexDirection") == "row"
388
+ setHorizontal(horizontal)
389
+ configureContainer(content, style)
390
+ reconcileChildren(screen, content, children, path, next, style, previousControls)
391
+ }
392
+ "FlatList" -> (previous as? CraftNativeFlatList ?: CraftNativeFlatList(this)).apply {
393
+ reconcileFlatList(screen, this, children, path, protocolId ?: path, props, events)
394
+ }
395
+ else -> (previous as? LinearLayout ?: LinearLayout(this)).apply {
396
+ configureContainer(this, style)
397
+ reconcileChildren(screen, this, children, path, next, style, previousControls)
398
+ }
399
+ }
400
+ if (type != "Button" && type != "TextInput") {
401
+ val handler = events.optString("onPress").ifBlank { events.optString("onClick") }
402
+ if (handler.isNotBlank()) {
403
+ screen.handlers[control] = handler
404
+ control.isClickable = true
405
+ control.setOnClickListener {
406
+ val current = screen.handlers[control] ?: return@setOnClickListener
407
+ send(screen, "EVENT", JSONObject().put("handlerName", current).put("nativeEvent", JSONObject()))
289
408
  }
290
- while (childCount > desired.size) removeViewAt(childCount - 1)
409
+ } else {
410
+ screen.handlers.remove(control)
411
+ control.isClickable = false
412
+ control.setOnClickListener(null)
291
413
  }
292
414
  }
293
- control.contentDescription = explicitKey ?: path
415
+ applyAccessibility(control, props, type, explicitKey ?: protocolId ?: path)
416
+ if (control is ImageView) imageErrors[control]?.let { control.contentDescription = "${control.contentDescription}, $it" }
294
417
  next[identity] = control
295
- if (style.has("backgroundColor")) control.setBackgroundColor(color(style.optString("backgroundColor"), Color.TRANSPARENT))
418
+ applyViewStyle(control, style)
296
419
  control.layoutParams = LinearLayout.LayoutParams(
297
420
  if (style.has("width")) dp(style.optDouble("width")) else ViewGroup.LayoutParams.MATCH_PARENT,
298
421
  if (style.has("height")) dp(style.optDouble("height")) else ViewGroup.LayoutParams.WRAP_CONTENT
299
- )
422
+ ).apply {
423
+ val margin = style.optDouble("margin", 0.0)
424
+ val horizontal = style.optDouble("marginHorizontal", margin)
425
+ val vertical = style.optDouble("marginVertical", margin)
426
+ setMargins(
427
+ dp(style.optDouble("marginLeft", horizontal)),
428
+ dp(style.optDouble("marginTop", vertical)),
429
+ dp(style.optDouble("marginRight", horizontal)),
430
+ dp(style.optDouble("marginBottom", vertical))
431
+ )
432
+ }
300
433
  return control
301
434
  }
302
435
 
@@ -307,6 +440,287 @@ class MainActivity : AppCompatActivity() {
307
440
  }
308
441
  }
309
442
 
443
+ private fun configureContainer(layout: LinearLayout, style: JSONObject) {
444
+ val direction = style.optString("flexDirection")
445
+ layout.orientation = if (direction == "row" || direction == "row-reverse")
446
+ LinearLayout.HORIZONTAL else LinearLayout.VERTICAL
447
+ val cross = when (style.optString("alignItems")) {
448
+ "center" -> if (layout.orientation == LinearLayout.HORIZONTAL) Gravity.CENTER_VERTICAL else Gravity.CENTER_HORIZONTAL
449
+ "flex-end" -> if (layout.orientation == LinearLayout.HORIZONTAL) Gravity.BOTTOM else Gravity.END
450
+ else -> if (layout.orientation == LinearLayout.HORIZONTAL) Gravity.TOP else Gravity.START
451
+ }
452
+ layout.gravity = cross
453
+ val padding = style.optDouble("padding", 0.0)
454
+ val horizontal = style.optDouble("paddingHorizontal", padding)
455
+ val vertical = style.optDouble("paddingVertical", padding)
456
+ layout.setPadding(
457
+ dp(style.optDouble("paddingLeft", horizontal)),
458
+ dp(style.optDouble("paddingTop", vertical)),
459
+ dp(style.optDouble("paddingRight", horizontal)),
460
+ dp(style.optDouble("paddingBottom", vertical))
461
+ )
462
+ }
463
+
464
+ private fun reconcileChildren(
465
+ screen: Screen,
466
+ layout: LinearLayout,
467
+ children: JSONArray,
468
+ path: String,
469
+ next: MutableMap<String, View>,
470
+ style: JSONObject,
471
+ previousControls: Map<String, View>,
472
+ ) {
473
+ for (index in layout.childCount - 1 downTo 0) {
474
+ if (layout.getChildAt(index) is CraftNativeFlexSpacer) layout.removeViewAt(index)
475
+ }
476
+ val desired = mutableListOf<View>()
477
+ for (index in 0 until children.length()) {
478
+ val child = children.opt(index)
479
+ val childView = when (child) {
480
+ is JSONObject -> makeView(screen, child, "$path.$index", next, previousControls)
481
+ is String -> TextView(this).apply { text = child; setTextColor(defaultTextColor) }
482
+ else -> null
483
+ }
484
+ if (childView != null) desired.add(childView)
485
+ }
486
+ if (style.optString("flexDirection").endsWith("-reverse")) desired.reverse()
487
+ val desiredSet = desired.toSet()
488
+ for ((index, childView) in desired.withIndex()) {
489
+ if (layout.getChildAt(index) !== childView) {
490
+ if (index < layout.childCount && layout.getChildAt(index) !in desiredSet) layout.removeViewAt(index)
491
+ (childView.parent as? ViewGroup)?.removeView(childView)
492
+ layout.addView(childView, index)
493
+ }
494
+ val gapName = if (layout.orientation == LinearLayout.HORIZONTAL) "columnGap" else "rowGap"
495
+ val gap = dp(style.optDouble(gapName, style.optDouble("gap", 0.0)))
496
+ val params = childView.layoutParams as? LinearLayout.LayoutParams
497
+ ?: LinearLayout.LayoutParams(childView.layoutParams?.width ?: -1, childView.layoutParams?.height ?: -2)
498
+ params.leftMargin = if (layout.orientation == LinearLayout.HORIZONTAL && index > 0) gap else 0
499
+ params.topMargin = if (layout.orientation == LinearLayout.VERTICAL && index > 0) gap else 0
500
+ childView.layoutParams = params
501
+ }
502
+ while (layout.childCount > desired.size) layout.removeViewAt(layout.childCount - 1)
503
+ addJustificationSpacers(layout, style.optString("justifyContent"))
504
+ }
505
+
506
+ private fun reconcileFlatList(
507
+ screen: Screen,
508
+ list: CraftNativeFlatList,
509
+ children: JSONArray,
510
+ path: String,
511
+ owner: String,
512
+ props: JSONObject,
513
+ events: JSONObject,
514
+ ) {
515
+ screen.flatListOwners.entries.removeAll { it.value == owner }
516
+ screen.flatListOwnerByView[list] = owner
517
+ val nodes = (0 until children.length()).mapNotNull { children.optJSONObject(it) }
518
+ nodes.forEach { registerFlatListOwnership(screen, it, owner) }
519
+ list.apply(
520
+ nodes = nodes,
521
+ horizontal = props.optBoolean("horizontal", false),
522
+ columns = props.optInt("numColumns", 1),
523
+ inverted = props.optBoolean("inverted", false),
524
+ endReachedThreshold = when {
525
+ props.has("onEndReachedThreshold") -> props.optDouble("onEndReachedThreshold", 0.1)
526
+ else -> props.optDouble("threshold", 0.1)
527
+ },
528
+ renderItem = { node, itemIdentity, _ ->
529
+ val rows = screen.flatListRows.getOrPut(list) { mutableMapOf() }
530
+ val previous = rows[itemIdentity].orEmpty()
531
+ val next = mutableMapOf<String, View>()
532
+ val view = makeView(screen, node, "$path.$itemIdentity", next, previous)
533
+ releaseViewState(screen, previous.values.filter { it !in next.values })
534
+ rows[itemIdentity] = next
535
+ view
536
+ },
537
+ recycleItem = { itemIdentity ->
538
+ val controls = screen.flatListRows[list]?.remove(itemIdentity).orEmpty()
539
+ releaseViewState(screen, controls.values.toList())
540
+ },
541
+ endReached = events.optString("onEndReached").takeIf { it.isNotBlank() }?.let { handler ->
542
+ { send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject())) }
543
+ },
544
+ )
545
+ }
546
+
547
+ private fun registerFlatListOwnership(screen: Screen, node: JSONObject, owner: String) {
548
+ val props = node.optJSONObject("props") ?: JSONObject()
549
+ listOf(node.optString("id"), node.optString("key"), props.optString("key"), props.optString("testID"))
550
+ .filter { it.isNotBlank() }
551
+ .forEach { screen.flatListOwners[it] = owner }
552
+ val children = node.optJSONArray("children") ?: JSONArray()
553
+ for (index in 0 until children.length()) {
554
+ children.optJSONObject(index)?.let { registerFlatListOwnership(screen, it, owner) }
555
+ }
556
+ }
557
+
558
+ private fun addJustificationSpacers(layout: LinearLayout, value: String) {
559
+ if (layout.childCount == 0 || value == "flex-start" || value.isBlank()) return
560
+ fun spacer(weight: Float): CraftNativeFlexSpacer = CraftNativeFlexSpacer(this).apply {
561
+ layoutParams = if (layout.orientation == LinearLayout.HORIZONTAL)
562
+ LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, weight)
563
+ else LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, weight)
564
+ }
565
+ when (value) {
566
+ "flex-end" -> layout.addView(spacer(1f), 0)
567
+ "center" -> {
568
+ layout.addView(spacer(1f), 0)
569
+ layout.addView(spacer(1f))
570
+ }
571
+ "space-between" -> for (index in layout.childCount - 1 downTo 1) layout.addView(spacer(1f), index)
572
+ "space-around", "space-evenly" -> {
573
+ val edgeWeight = if (value == "space-around") 0.5f else 1f
574
+ for (index in layout.childCount - 1 downTo 1) layout.addView(spacer(1f), index)
575
+ layout.addView(spacer(edgeWeight), 0)
576
+ layout.addView(spacer(edgeWeight))
577
+ }
578
+ }
579
+ }
580
+
581
+ private fun applyViewStyle(view: View, style: JSONObject) {
582
+ view.alpha = style.optDouble("opacity", 1.0).toFloat()
583
+ view.visibility = if (style.optString("display") == "none") View.GONE else View.VISIBLE
584
+ view.elevation = dp(style.optDouble("elevation", 0.0)).toFloat()
585
+ val background = style.has("backgroundColor")
586
+ val border = style.optDouble("borderWidth", 0.0)
587
+ val radius = style.optDouble("borderRadius", 0.0)
588
+ if (background || border > 0 || radius > 0) {
589
+ view.background = GradientDrawable().apply {
590
+ setColor(color(style.optString("backgroundColor"), Color.TRANSPARENT))
591
+ cornerRadius = dp(radius).toFloat()
592
+ if (border > 0) setStroke(dp(border), color(style.optString("borderColor"), Color.TRANSPARENT))
593
+ }
594
+ }
595
+ view.clipToOutline = style.optString("overflow") == "hidden"
596
+ if (view is ViewGroup) view.clipChildren = style.optString("overflow") == "hidden"
597
+ if (view is ImageView) {
598
+ if (style.has("tintColor")) view.setColorFilter(color(style.optString("tintColor"), Color.TRANSPARENT))
599
+ else view.clearColorFilter()
600
+ }
601
+ }
602
+
603
+ private fun configureText(view: TextView, value: String, style: JSONObject) {
604
+ view.text = when (style.optString("textTransform")) {
605
+ "uppercase" -> value.uppercase()
606
+ "lowercase" -> value.lowercase()
607
+ "capitalize" -> value.split(' ').joinToString(" ") { word -> word.replaceFirstChar(Char::uppercase) }
608
+ else -> value
609
+ }
610
+ view.setTextColor(color(style.optString("color"), defaultTextColor))
611
+ view.setTextSize(TypedValue.COMPLEX_UNIT_SP, style.optDouble("fontSize", 14.0).toFloat())
612
+ val weight = when (style.optString("fontWeight")) {
613
+ "bold" -> 700
614
+ "100", "200", "300", "400", "500", "600", "700", "800", "900" -> style.optInt("fontWeight")
615
+ else -> 400
616
+ }
617
+ val italic = style.optString("fontStyle") == "italic"
618
+ val family = style.optString("fontFamily").takeIf { it.isNotBlank() }
619
+ view.typeface = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
620
+ Typeface.create(Typeface.create(family, Typeface.NORMAL), weight, italic)
621
+ } else Typeface.create(family, (if (weight >= 600) Typeface.BOLD else Typeface.NORMAL) or
622
+ (if (italic) Typeface.ITALIC else Typeface.NORMAL))
623
+ view.textAlignment = when (style.optString("textAlign")) {
624
+ "left" -> View.TEXT_ALIGNMENT_TEXT_START
625
+ "right" -> View.TEXT_ALIGNMENT_TEXT_END
626
+ "center" -> View.TEXT_ALIGNMENT_CENTER
627
+ "justify" -> View.TEXT_ALIGNMENT_VIEW_START
628
+ else -> View.TEXT_ALIGNMENT_INHERIT
629
+ }
630
+ view.letterSpacing = if (style.has("letterSpacing") && view.textSize > 0)
631
+ dp(style.optDouble("letterSpacing")).toFloat() / view.textSize else 0f
632
+ view.setLineSpacing(
633
+ if (style.has("lineHeight")) dp(style.optDouble("lineHeight")) - view.textSize else 0f,
634
+ 1f
635
+ )
636
+ view.paintFlags = view.paintFlags and Paint.UNDERLINE_TEXT_FLAG.inv() and Paint.STRIKE_THRU_TEXT_FLAG.inv()
637
+ when (style.optString("textDecorationLine")) {
638
+ "underline" -> view.paintFlags = view.paintFlags or Paint.UNDERLINE_TEXT_FLAG
639
+ "line-through" -> view.paintFlags = view.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG
640
+ "underline line-through" -> view.paintFlags = view.paintFlags or
641
+ Paint.UNDERLINE_TEXT_FLAG or Paint.STRIKE_THRU_TEXT_FLAG
642
+ }
643
+ }
644
+
645
+ private fun updateImage(view: ImageView, source: Any?) {
646
+ val uri = when (source) {
647
+ is String -> source
648
+ is JSONObject -> source.optString("uri")
649
+ else -> ""
650
+ }
651
+ if (uri.isBlank()) {
652
+ imageJobs.remove(view)?.cancel(true)
653
+ imageSources.remove(view)
654
+ imageErrors[view] = "Image source is missing"
655
+ view.setImageDrawable(null)
656
+ return
657
+ }
658
+ if (imageSources[view] == uri) return
659
+ imageJobs.remove(view)?.cancel(true)
660
+ imageSources[view] = uri
661
+ imageErrors.remove(view)
662
+ view.setImageDrawable(null)
663
+
664
+ if (uri.startsWith("data:image/") && uri.contains(',')) {
665
+ val bytes = runCatching { Base64.decode(uri.substringAfter(','), Base64.DEFAULT) }.getOrNull()
666
+ val bitmap = bytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
667
+ if (bitmap != null) view.setImageBitmap(bitmap) else imageFailure(view, uri, "Image data is invalid")
668
+ return
669
+ }
670
+ if (!uri.contains(':')) {
671
+ val bitmap = runCatching { assets.open(uri).use { BitmapFactory.decodeStream(it) } }.getOrNull()
672
+ if (bitmap != null) view.setImageBitmap(bitmap) else imageFailure(view, uri, "Bundled image was not found")
673
+ return
674
+ }
675
+ if (!uri.startsWith("https://")) {
676
+ imageFailure(view, uri, "Unsupported image source")
677
+ return
678
+ }
679
+ imageJobs[view] = imageExecutor.submit {
680
+ val bitmap = runCatching {
681
+ val connection = URL(uri).openConnection().apply {
682
+ connectTimeout = 10_000
683
+ readTimeout = 10_000
684
+ }
685
+ connection.getInputStream().use(BitmapFactory::decodeStream)
686
+ }.getOrNull()
687
+ runOnUiThread {
688
+ if (imageSources[view] != uri) return@runOnUiThread
689
+ imageJobs.remove(view)
690
+ if (bitmap != null) view.setImageBitmap(bitmap) else imageFailure(view, uri, "Image download failed")
691
+ }
692
+ }
693
+ }
694
+
695
+ private fun imageFailure(view: ImageView, uri: String, message: String) {
696
+ imageErrors[view] = message
697
+ view.setImageDrawable(null)
698
+ view.contentDescription = "${view.tag ?: "Image"}, $message"
699
+ Log.e(TAG, "$message: $uri")
700
+ }
701
+
702
+ private fun applyAccessibility(view: View, props: JSONObject, type: String, fallbackLabel: String) {
703
+ view.tag = fallbackLabel
704
+ view.contentDescription = props.optString("accessibilityLabel").ifBlank { fallbackLabel }
705
+ props.optString("accessibilityHint").takeIf { it.isNotBlank() }?.let { view.tooltipText = it }
706
+ val role = props.optString("accessibilityRole").ifBlank { type.lowercase() }
707
+ ViewCompat.setAccessibilityDelegate(view, object : AccessibilityDelegateCompat() {
708
+ override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) {
709
+ super.onInitializeAccessibilityNodeInfo(host, info)
710
+ info.className = when (role) {
711
+ "button" -> Button::class.java.name
712
+ "image" -> ImageView::class.java.name
713
+ "search" -> EditText::class.java.name
714
+ "scrollbar" -> ScrollView::class.java.name
715
+ else -> host.javaClass.name
716
+ }
717
+ info.isHeading = role == "header"
718
+ info.roleDescription = role.takeIf { it !in setOf("view", "text", "textinput", "scrollview") }
719
+ }
720
+ })
721
+ ViewCompat.setImportantForAccessibility(view, ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES)
722
+ }
723
+
310
724
  private fun dp(value: Double) = (value * resources.displayMetrics.density).toInt()
311
725
  private fun color(raw: String, fallback: Int) = runCatching { Color.parseColor(raw) }.getOrDefault(fallback)
312
726
 
@@ -358,13 +772,47 @@ class MainActivity : AppCompatActivity() {
358
772
 
359
773
  private fun back() {
360
774
  if (screens.size > 1) {
361
- screens.removeAt(screens.lastIndex).close()
775
+ closeScreen(screens.removeAt(screens.lastIndex))
362
776
  show(screens.last())
363
777
  } else {
364
778
  finish()
365
779
  }
366
780
  }
367
781
 
782
+ private fun releaseImages(views: Collection<View>) {
783
+ views.filterIsInstance<ImageView>().forEach { image ->
784
+ imageJobs.remove(image)?.cancel(true)
785
+ imageSources.remove(image)
786
+ imageErrors.remove(image)
787
+ }
788
+ }
789
+
790
+ private fun retainedViews(screen: Screen): MutableSet<View> = screen.controls.values.toMutableSet().apply {
791
+ screen.flatListRows.values.forEach { rows -> rows.values.forEach { addAll(it.values) } }
792
+ }
793
+
794
+ private fun releaseViewState(screen: Screen, views: Collection<View>) {
795
+ val released = views.toSet()
796
+ released.filterIsInstance<CraftNativeFlatList>().forEach { list ->
797
+ val owner = screen.flatListOwnerByView.remove(list)
798
+ list.discardAll()
799
+ screen.flatListRows.remove(list)?.values?.forEach { releaseViewState(screen, it.values) }
800
+ if (owner != null) screen.flatListOwners.entries.removeAll { it.value == owner }
801
+ }
802
+ releaseImages(released)
803
+ screen.handlers.keys.removeAll(released)
804
+ }
805
+
806
+ private fun closeScreen(screen: Screen) {
807
+ releaseViewState(screen, retainedViews(screen).toList())
808
+ screen.controls.clear()
809
+ screen.handlers.clear()
810
+ screen.flatListRows.clear()
811
+ screen.flatListOwners.clear()
812
+ screen.flatListOwnerByView.clear()
813
+ screen.close()
814
+ }
815
+
368
816
  @Deprecated("Uses the activity back callback for native route navigation")
369
817
  override fun onBackPressed() = back()
370
818
 
@@ -380,8 +828,11 @@ class MainActivity : AppCompatActivity() {
380
828
  }
381
829
 
382
830
  override fun onDestroy() {
383
- screens.forEach { it.close() }
831
+ screens.forEach { closeScreen(it) }
384
832
  screens.clear()
833
+ imageJobs.values.forEach { it.cancel(true) }
834
+ imageJobs.clear()
835
+ imageExecutor.shutdownNow()
385
836
  sandbox?.close()
386
837
  super.onDestroy()
387
838
  }