craft-native 0.0.113 → 0.0.115

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.
@@ -415,6 +415,12 @@ async function init(options) {
415
415
  const mainActivityTemplate = readFileSync(join(TEMPLATES_DIR, config.renderer === "native" ? "MainActivityNative.kt.template" : "MainActivity.kt.template"), "utf-8");
416
416
  const mainActivity = mainActivityTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{APP_NAME\}\}/g, name);
417
417
  writeFileSync(join(output, "app/src/main/java", packagePath, "MainActivity.kt"), mainActivity);
418
+ if (config.renderer === "native") {
419
+ const mutationTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeMutation.kt.template"), "utf-8");
420
+ const flatListTemplate = readFileSync(join(TEMPLATES_DIR, "CraftNativeFlatList.kt.template"), "utf-8");
421
+ writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeMutation.kt"), mutationTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
422
+ writeFileSync(join(output, "app/src/main/java", packagePath, "CraftNativeFlatList.kt"), flatListTemplate.replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName));
423
+ }
418
424
  const craftBridgeTemplate = readFileSync(join(TEMPLATES_DIR, "CraftBridge.kt.template"), "utf-8");
419
425
  const craftBridge = craftBridgeTemplate.replace(/\{\{PROMISE_RUNTIME\}\}/g, () => renderAndroidPromiseRuntime(" ")).replace(/\{\{PACKAGE_NAME\}\}/g, finalPackageName).replace(/\{\{ENABLE_SPEECH\}\}/g, String(Boolean(config.enableSpeechRecognition))).replace(/\{\{ENABLE_HAPTICS\}\}/g, String(Boolean(config.enableHaptics))).replace(/\{\{ENABLE_SHARE\}\}/g, String(Boolean(config.enableShare))).replace(/\{\{ENABLE_CAMERA\}\}/g, String(Boolean(config.enableCamera))).replace(/\{\{ENABLE_BIOMETRIC\}\}/g, String(Boolean(config.enableBiometric))).replace(/\{\{ENABLE_PUSH\}\}/g, String(Boolean(config.enablePushNotifications))).replace(/\{\{ENABLE_SECURE_STORAGE\}\}/g, String(Boolean(config.enableSecureStorage))).replace(/\{\{ENABLE_GEOLOCATION\}\}/g, String(Boolean(config.enableGeolocation))).replace(/\{\{ENABLE_BACKGROUND_LOCATION\}\}/g, String(Boolean(config.enableBackgroundLocation))).replace(/\{\{ENABLE_KEEP_AWAKE\}\}/g, String(Boolean(config.enableKeepAwake))).replace(/\{\{ENABLE_DEEP_LINKS\}\}/g, String(Boolean(config.enableDeepLinks))).replace(/\{\{ENABLE_HEALTH_CONNECT\}\}/g, String(Boolean(config.enableHealthConnect))).replace(/\{\{FIREBASE_IMPORT\}\}/g, config.enablePushNotifications ? "import com.google.firebase.messaging.FirebaseMessaging" : "").replace(/\{\{REGISTER_PUSH_IMPLEMENTATION\}\}/g, config.enablePushNotifications ? `activity.runOnUiThread {
420
426
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
@@ -0,0 +1,181 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import android.content.Context
4
+ import android.view.View
5
+ import android.view.ViewGroup
6
+ import android.widget.FrameLayout
7
+ import androidx.recyclerview.widget.DiffUtil
8
+ import androidx.recyclerview.widget.GridLayoutManager
9
+ import androidx.recyclerview.widget.LinearLayoutManager
10
+ import androidx.recyclerview.widget.ListAdapter
11
+ import androidx.recyclerview.widget.RecyclerView
12
+ import org.json.JSONObject
13
+ import kotlin.math.ceil
14
+
15
+ internal typealias CraftNativeRenderItem = (node: JSONObject, identity: String, previous: View?) -> View
16
+
17
+ /** A stable-key RecyclerView host for stx-native FlatList nodes. */
18
+ internal class CraftNativeFlatList(context: Context) : RecyclerView(context) {
19
+ private data class Item(
20
+ val identity: String,
21
+ val node: JSONObject,
22
+ val signature: String,
23
+ val chrome: Boolean,
24
+ )
25
+
26
+ private class Holder(val host: FrameLayout) : ViewHolder(host) {
27
+ var identity: String? = null
28
+ val hostedView: View? get() = host.getChildAt(0)
29
+ }
30
+
31
+ private val listAdapter = object : ListAdapter<Item, Holder>(object : DiffUtil.ItemCallback<Item>() {
32
+ override fun areItemsTheSame(oldItem: Item, newItem: Item) = oldItem.identity == newItem.identity
33
+ override fun areContentsTheSame(oldItem: Item, newItem: Item) = oldItem.signature == newItem.signature
34
+ }) {
35
+ init { setHasStableIds(true) }
36
+
37
+ override fun getItemId(position: Int): Long = stableId(getItem(position).identity)
38
+
39
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = Holder(FrameLayout(parent.context).apply {
40
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
41
+ })
42
+
43
+ override fun onBindViewHolder(holder: Holder, position: Int) {
44
+ val item = getItem(position)
45
+ if (holder.identity != null && holder.identity != item.identity) release(holder)
46
+ val view = renderItem?.invoke(item.node, item.identity, holder.hostedView) ?: View(holder.host.context)
47
+ holder.identity = item.identity
48
+ if (holder.hostedView !== view) {
49
+ (view.parent as? ViewGroup)?.removeView(view)
50
+ holder.host.removeAllViews()
51
+ holder.host.addView(view, FrameLayout.LayoutParams(
52
+ LayoutParams.MATCH_PARENT,
53
+ LayoutParams.WRAP_CONTENT,
54
+ ))
55
+ }
56
+ }
57
+
58
+ override fun onViewRecycled(holder: Holder) {
59
+ release(holder)
60
+ super.onViewRecycled(holder)
61
+ }
62
+
63
+ private fun release(holder: Holder) {
64
+ holder.identity?.let { recycleItem?.invoke(it) }
65
+ holder.host.removeAllViews()
66
+ holder.identity = null
67
+ }
68
+ }
69
+
70
+ private var renderItem: CraftNativeRenderItem? = null
71
+ private var recycleItem: ((String) -> Unit)? = null
72
+ private var endReached: (() -> Unit)? = null
73
+ private var threshold = 0.1
74
+ private var endReachedSignature: String? = null
75
+ private var horizontal = false
76
+ private var columns = 1
77
+
78
+ init {
79
+ adapter = listAdapter
80
+ itemAnimator = null
81
+ addOnScrollListener(object : OnScrollListener() {
82
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) = evaluateEndReached()
83
+ })
84
+ }
85
+
86
+ fun apply(
87
+ nodes: List<JSONObject>,
88
+ horizontal: Boolean,
89
+ columns: Int,
90
+ inverted: Boolean,
91
+ endReachedThreshold: Double,
92
+ renderItem: CraftNativeRenderItem,
93
+ recycleItem: (String) -> Unit,
94
+ endReached: (() -> Unit)?,
95
+ ) {
96
+ this.renderItem = renderItem
97
+ this.recycleItem = recycleItem
98
+ this.endReached = endReached
99
+ threshold = endReachedThreshold.coerceAtLeast(0.0)
100
+
101
+ val nextColumns = columns.coerceAtLeast(1)
102
+ val layoutChanged = this.horizontal != horizontal || this.columns != nextColumns
103
+ val retainedState = if (layoutChanged) null else layoutManager?.onSaveInstanceState()
104
+ this.horizontal = horizontal
105
+ this.columns = nextColumns
106
+ if (layoutChanged || layoutManager == null) configureLayoutManager()
107
+
108
+ val seen = mutableMapOf<String, Int>()
109
+ val next = nodes.mapIndexed { index, node ->
110
+ val base = identity(node) ?: "index:$index"
111
+ val occurrence = seen.getOrDefault(base, 0)
112
+ seen[base] = occurrence + 1
113
+ val unique = if (occurrence == 0) base else "$base#$occurrence"
114
+ val role = node.optJSONObject("props")?.optString("listRole").orEmpty()
115
+ Item(unique, node, node.toString(), role.isNotBlank() && role != "item")
116
+ }.let { if (inverted) it.reversed() else it }
117
+
118
+ val oldIds = listAdapter.currentList.mapTo(mutableSetOf()) { it.identity }
119
+ val nextIds = next.mapTo(mutableSetOf()) { it.identity }
120
+ (oldIds - nextIds).forEach(recycleItem)
121
+ val dataItems = next.filterNot { it.chrome }
122
+ val contentSignature = "${dataItems.size}:${dataItems.lastOrNull()?.identity.orEmpty()}"
123
+ if (contentSignature != endReachedSignature) endReachedSignature = null
124
+ listAdapter.submitList(next) {
125
+ retainedState?.let { layoutManager?.onRestoreInstanceState(it) }
126
+ evaluateEndReached()
127
+ }
128
+ }
129
+
130
+ fun discardAll() {
131
+ listAdapter.currentList.forEach { recycleItem?.invoke(it.identity) }
132
+ listAdapter.submitList(emptyList())
133
+ renderItem = null
134
+ recycleItem = null
135
+ endReached = null
136
+ }
137
+
138
+ private fun configureLayoutManager() {
139
+ layoutManager = when {
140
+ horizontal -> LinearLayoutManager(context, HORIZONTAL, false)
141
+ columns > 1 -> GridLayoutManager(context, columns).also { grid ->
142
+ grid.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
143
+ override fun getSpanSize(position: Int): Int =
144
+ if (listAdapter.currentList.getOrNull(position)?.chrome == true) columns else 1
145
+ }
146
+ }
147
+ else -> LinearLayoutManager(context)
148
+ }
149
+ }
150
+
151
+ private fun evaluateEndReached() {
152
+ val manager = layoutManager as? LinearLayoutManager ?: return
153
+ val lastVisible = manager.findLastVisibleItemPosition()
154
+ val dataPositions = listAdapter.currentList.indices.filter { !listAdapter.currentList[it].chrome }
155
+ val lastDataOrdinal = dataPositions.indexOfLast { it <= lastVisible }
156
+ if (dataPositions.isEmpty() || lastDataOrdinal < 0 || endReached == null) return
157
+ val remaining = dataPositions.size - lastDataOrdinal - 1
158
+ val thresholdItems = maxOf(1, ceil(dataPositions.size * threshold).toInt())
159
+ if (remaining > thresholdItems) return
160
+ val lastIdentity = listAdapter.currentList[dataPositions.last()].identity
161
+ val signature = "${dataPositions.size}:$lastIdentity"
162
+ if (endReachedSignature == signature) return
163
+ endReachedSignature = signature
164
+ endReached?.invoke()
165
+ }
166
+
167
+ private fun identity(node: JSONObject): String? {
168
+ val props = node.optJSONObject("props") ?: JSONObject()
169
+ return listOf(node.optString("id"), node.optString("key"), props.optString("key"), props.optString("testID"))
170
+ .firstOrNull { it.isNotBlank() }
171
+ }
172
+
173
+ private fun stableId(value: String): Long {
174
+ var hash = -0x340d631b7bdddcdbL
175
+ for (byte in value.encodeToByteArray()) {
176
+ hash = hash xor (byte.toLong() and 0xff)
177
+ hash *= 0x100000001b3L
178
+ }
179
+ return hash
180
+ }
181
+ }
@@ -0,0 +1,335 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import org.json.JSONArray
4
+ import org.json.JSONObject
5
+
6
+ const val CRAFT_NATIVE_MUTATION_PROTOCOL_VERSION = 1
7
+
8
+ data class CraftNativeMutationFailure(
9
+ val code: String,
10
+ override val message: String,
11
+ val operationIndex: Int? = null
12
+ ) : Exception(message)
13
+
14
+ data class CraftNativeMutationResult(
15
+ val batchId: String,
16
+ val revision: Int,
17
+ val document: JSONObject?,
18
+ val updatedNodeIds: List<String>,
19
+ val affectedNodeIds: List<String>,
20
+ val requiresFullRender: Boolean
21
+ )
22
+
23
+ /** Validates a complete batch against a copy before committing any state. */
24
+ class CraftNativeMutationDocument {
25
+ private data class Node(
26
+ val id: String,
27
+ var type: String,
28
+ var props: JSONObject,
29
+ var style: JSONObject,
30
+ var events: JSONObject,
31
+ var text: MutableList<String>,
32
+ var children: MutableList<String>,
33
+ var parent: String?
34
+ ) {
35
+ fun duplicate(): Node = copy(
36
+ props = JSONObject(props.toString()),
37
+ style = JSONObject(style.toString()),
38
+ events = JSONObject(events.toString()),
39
+ text = text.toMutableList(),
40
+ children = children.toMutableList()
41
+ )
42
+ }
43
+
44
+ private var nodes = linkedMapOf<String, Node>()
45
+ private var rootId: String? = null
46
+ var revision: Int = 0
47
+ private set
48
+
49
+ fun replace(document: JSONObject) {
50
+ val replacement = linkedMapOf<String, Node>()
51
+ val root = importNode(document, "root", null, replacement)
52
+ nodes = replacement
53
+ rootId = root
54
+ revision = 0
55
+ }
56
+
57
+ fun apply(payload: JSONObject): CraftNativeMutationResult {
58
+ val batchId = payload.optString("batchId")
59
+ if (batchId.isBlank()) fail("INVALID_BATCH", "batchId must be a non-empty string")
60
+ if (strictInteger(payload.opt("version")) != CRAFT_NATIVE_MUTATION_PROTOCOL_VERSION) {
61
+ fail(
62
+ "UNSUPPORTED_VERSION",
63
+ "mutation protocol version must be $CRAFT_NATIVE_MUTATION_PROTOCOL_VERSION"
64
+ )
65
+ }
66
+ if (strictInteger(payload.opt("baseRevision")) != revision) {
67
+ fail("REVISION_MISMATCH", "baseRevision must equal the current revision $revision")
68
+ }
69
+ val nextRevision = strictInteger(payload.opt("revision"))
70
+ if (nextRevision != revision + 1) {
71
+ fail("INVALID_REVISION", "revision must advance exactly once from $revision")
72
+ }
73
+ val operations = payload.opt("operations") as? JSONArray
74
+ if (operations == null || operations.length() == 0) {
75
+ fail("INVALID_BATCH", "operations must be a non-empty array")
76
+ }
77
+
78
+ val candidateNodes = linkedMapOf<String, Node>().apply {
79
+ nodes.forEach { (id, node) -> put(id, node.duplicate()) }
80
+ }
81
+ var candidateRoot = rootId
82
+ val updatedNodeIds = mutableListOf<String>()
83
+ val affectedNodeIds = mutableListOf<String>()
84
+ var requiresFullRender = false
85
+ for (index in 0 until operations.length()) {
86
+ val operation = operations.opt(index) as? JSONObject
87
+ ?: failAt("INVALID_OPERATION", "operation must be an object with an op string", index)
88
+ val name = operation.opt("op") as? String
89
+ if (name.isNullOrBlank()) {
90
+ failAt("INVALID_OPERATION", "operation must be an object with an op string", index)
91
+ }
92
+ try {
93
+ when (name) {
94
+ "createNode" -> {
95
+ if (operation.opt("root") == true) requiresFullRender = true
96
+ candidateRoot = createNode(operation, candidateNodes, candidateRoot)
97
+ }
98
+ "updateNode" -> {
99
+ updateNode(operation, candidateNodes)
100
+ operation.optString("id").let {
101
+ if (it !in updatedNodeIds) updatedNodeIds.add(it)
102
+ if (it !in affectedNodeIds) affectedNodeIds.add(it)
103
+ }
104
+ }
105
+ "insertChild" -> {
106
+ insertChild(operation, candidateNodes)
107
+ operation.optString("parentId").takeIf { it !in affectedNodeIds }?.let { affectedNodeIds.add(it) }
108
+ }
109
+ "moveChild" -> {
110
+ moveChild(operation, candidateNodes)
111
+ operation.optString("parentId").takeIf { it !in affectedNodeIds }?.let { affectedNodeIds.add(it) }
112
+ }
113
+ "removeNode" -> {
114
+ val parent = candidateNodes[operation.optString("id")]?.parent
115
+ if (parent == null) requiresFullRender = true
116
+ else if (parent !in affectedNodeIds) affectedNodeIds.add(parent)
117
+ candidateRoot = removeNode(operation, candidateNodes, candidateRoot)
118
+ }
119
+ else -> fail("UNKNOWN_OPERATION", "unsupported operation $name")
120
+ }
121
+ }
122
+ catch (failure: CraftNativeMutationFailure) {
123
+ throw CraftNativeMutationFailure(failure.code, failure.message, index)
124
+ }
125
+ }
126
+ if (candidateRoot == null && candidateNodes.isNotEmpty()) {
127
+ fail("MISSING_ROOT", "a non-empty document must have one root node")
128
+ }
129
+ if (candidateRoot != null) {
130
+ val root = candidateNodes[candidateRoot]
131
+ if (root == null || root.parent != null) {
132
+ fail("INVALID_TREE", "root node must not have a parent")
133
+ }
134
+ if (descendants(candidateRoot, candidateNodes).size + 1 != candidateNodes.size) {
135
+ fail("INVALID_TREE", "all nodes must be reachable from the root")
136
+ }
137
+ }
138
+
139
+ nodes = candidateNodes
140
+ rootId = candidateRoot
141
+ revision = nextRevision!!
142
+ return CraftNativeMutationResult(
143
+ batchId,
144
+ revision,
145
+ candidateRoot?.let { materialize(it, candidateNodes) },
146
+ updatedNodeIds,
147
+ affectedNodeIds,
148
+ requiresFullRender
149
+ )
150
+ }
151
+
152
+ fun node(id: String): JSONObject? = materialize(id, nodes)
153
+
154
+ private fun createNode(
155
+ operation: JSONObject,
156
+ nodes: MutableMap<String, Node>,
157
+ root: String?
158
+ ): String? {
159
+ val id = requiredId(operation, "id", "createNode")
160
+ if (nodes.containsKey(id)) fail("DUPLICATE_NODE", "node $id already exists")
161
+ val value = operation.opt("node") as? JSONObject
162
+ ?: fail("INVALID_NODE", "createNode.node.type must be a non-empty string")
163
+ val type = value.opt("type") as? String
164
+ if (type.isNullOrBlank()) fail("INVALID_NODE", "createNode.node.type must be a non-empty string")
165
+ val makeRoot = operation.opt("root") == true
166
+ if (makeRoot && root != null) fail("ROOT_EXISTS", "the document already has a root node")
167
+ nodes[id] = Node(
168
+ id,
169
+ type,
170
+ dictionary(value.opt("props")),
171
+ dictionary(value.opt("style")),
172
+ dictionary(value.opt("events")),
173
+ textChildren(if (value.has("children")) value.opt("children") else value.opt("text")),
174
+ mutableListOf(),
175
+ null
176
+ )
177
+ return if (makeRoot) id else root
178
+ }
179
+
180
+ private fun updateNode(operation: JSONObject, nodes: MutableMap<String, Node>) {
181
+ val id = requiredId(operation, "id", "updateNode")
182
+ val node = nodes[id] ?: fail("UNKNOWN_NODE", "node $id does not exist")
183
+ val patch = operation.opt("patch") as? JSONObject
184
+ ?: fail("INVALID_PATCH", "updateNode.patch must be an object")
185
+ val allowed = setOf("props", "style", "events", "children", "text")
186
+ val unknown = patch.keys().asSequence().filter { it !in allowed }.sorted().firstOrNull()
187
+ if (unknown != null) fail("INVALID_PATCH", "unsupported patch field $unknown")
188
+ if (patch.has("props")) node.props = dictionary(patch.opt("props"))
189
+ if (patch.has("style")) node.style = dictionary(patch.opt("style"))
190
+ if (patch.has("events")) node.events = dictionary(patch.opt("events"))
191
+ if (patch.has("children") || patch.has("text")) {
192
+ node.text = textChildren(if (patch.has("children")) patch.opt("children") else patch.opt("text"))
193
+ }
194
+ }
195
+
196
+ private fun insertChild(operation: JSONObject, nodes: MutableMap<String, Node>) {
197
+ val parentId = requiredId(operation, "parentId", "insertChild")
198
+ val childId = requiredId(operation, "childId", "insertChild")
199
+ val parent = nodes[parentId] ?: fail("UNKNOWN_PARENT", "parent node $parentId does not exist")
200
+ val child = nodes[childId] ?: fail("UNKNOWN_NODE", "node $childId does not exist")
201
+ if (!acceptsChildren(parent.type)) fail("INVALID_PARENT", "node $parentId cannot contain child nodes")
202
+ if (child.parent != null) fail("NODE_ATTACHED", "node $childId already has a parent")
203
+ if (parentId == childId || parentId in descendants(childId, nodes)) {
204
+ fail("CYCLE", "inserting node $childId would create a cycle")
205
+ }
206
+ val index = insertionIndex(operation, parent.children.size)
207
+ parent.children.add(index, childId)
208
+ child.parent = parentId
209
+ }
210
+
211
+ private fun moveChild(operation: JSONObject, nodes: MutableMap<String, Node>) {
212
+ val parentId = requiredId(operation, "parentId", "moveChild")
213
+ val childId = requiredId(operation, "childId", "moveChild")
214
+ val parent = nodes[parentId] ?: fail("UNKNOWN_PARENT", "parent node $parentId does not exist")
215
+ val oldIndex = parent.children.indexOf(childId)
216
+ if (nodes[childId]?.parent != parentId || oldIndex < 0) {
217
+ fail("NOT_A_CHILD", "node $childId is not a child of $parentId")
218
+ }
219
+ parent.children.removeAt(oldIndex)
220
+ parent.children.add(insertionIndex(operation, parent.children.size), childId)
221
+ }
222
+
223
+ private fun removeNode(
224
+ operation: JSONObject,
225
+ nodes: MutableMap<String, Node>,
226
+ root: String?
227
+ ): String? {
228
+ val id = requiredId(operation, "id", "removeNode")
229
+ val node = nodes[id] ?: fail("UNKNOWN_NODE", "node $id does not exist")
230
+ node.parent?.let { nodes[it]?.children?.remove(id) }
231
+ (descendants(id, nodes) + id).forEach { nodes.remove(it) }
232
+ return if (root == id) null else root
233
+ }
234
+
235
+ private fun requiredId(operation: JSONObject, field: String, name: String): String {
236
+ val id = operation.opt(field) as? String
237
+ if (id.isNullOrBlank()) fail("INVALID_NODE", "$name.$field must be a non-empty string")
238
+ return id
239
+ }
240
+
241
+ private fun insertionIndex(operation: JSONObject, count: Int): Int {
242
+ val index = strictInteger(operation.opt("index"))
243
+ if (index == null || index < 0 || index > count) {
244
+ fail("INVALID_INDEX", "index must be between 0 and $count")
245
+ }
246
+ return index
247
+ }
248
+
249
+ private fun textChildren(value: Any?): MutableList<String> {
250
+ if (value == null || value == JSONObject.NULL) return mutableListOf()
251
+ if (value is String) return mutableListOf(value)
252
+ val values = value as? JSONArray
253
+ ?: fail("INVALID_NODE", "node children may contain text only; use insertChild for node relationships")
254
+ val result = mutableListOf<String>()
255
+ for (index in 0 until values.length()) {
256
+ val text = values.opt(index) as? String
257
+ ?: fail("INVALID_NODE", "node children may contain text only; use insertChild for node relationships")
258
+ result.add(text)
259
+ }
260
+ return result
261
+ }
262
+
263
+ private fun acceptsChildren(type: String): Boolean =
264
+ type == "View" || type == "SafeAreaView" || type == "ScrollView"
265
+
266
+ private fun descendants(id: String, nodes: Map<String, Node>): Set<String> {
267
+ val result = linkedSetOf<String>()
268
+ nodes[id]?.children?.forEach { child ->
269
+ result.add(child)
270
+ result.addAll(descendants(child, nodes))
271
+ }
272
+ return result
273
+ }
274
+
275
+ private fun materialize(id: String, nodes: Map<String, Node>): JSONObject? {
276
+ val node = nodes[id] ?: return null
277
+ val children = JSONArray()
278
+ node.text.forEach { children.put(it) }
279
+ node.children.forEach { child -> materialize(child, nodes)?.let { children.put(it) } }
280
+ return JSONObject()
281
+ .put("id", node.id)
282
+ .put("type", node.type)
283
+ .put("props", node.props)
284
+ .put("style", node.style)
285
+ .put("events", node.events)
286
+ .put("children", children)
287
+ }
288
+
289
+ private fun importNode(
290
+ value: JSONObject,
291
+ preferredId: String,
292
+ parent: String?,
293
+ nodes: MutableMap<String, Node>
294
+ ): String {
295
+ val props = dictionary(value.opt("props"))
296
+ val explicit = (value.opt("id") as? String)?.takeIf { it.isNotBlank() }
297
+ val key = listOf(value.opt("key"), props.opt("key"), props.opt("testID"))
298
+ .filterIsInstance<String>().firstOrNull { it.isNotBlank() }
299
+ var id = explicit ?: key?.let { "${parent ?: "root"}/key:$it" } ?: preferredId
300
+ if (nodes.containsKey(id)) id = "$preferredId#${nodes.size}"
301
+ val children = value.optJSONArray("children") ?: JSONArray()
302
+ val node = Node(
303
+ id,
304
+ value.optString("type", "View"),
305
+ props,
306
+ dictionary(value.opt("style")),
307
+ dictionary(value.opt("events")),
308
+ mutableListOf(),
309
+ mutableListOf(),
310
+ parent
311
+ )
312
+ nodes[id] = node
313
+ for (index in 0 until children.length()) {
314
+ when (val child = children.opt(index)) {
315
+ is String -> node.text.add(child)
316
+ is JSONObject -> node.children.add(importNode(child, "$id/index:$index", id, nodes))
317
+ }
318
+ }
319
+ return id
320
+ }
321
+
322
+ private fun dictionary(value: Any?): JSONObject =
323
+ if (value is JSONObject) JSONObject(value.toString()) else JSONObject()
324
+
325
+ private fun strictInteger(value: Any?): Int? {
326
+ if (value !is Number) return null
327
+ val integer = value.toInt()
328
+ return integer.takeIf { value.toDouble() == integer.toDouble() }
329
+ }
330
+
331
+ private fun fail(code: String, message: String): Nothing = throw CraftNativeMutationFailure(code, message)
332
+
333
+ private fun failAt(code: String, message: String, index: Int): Nothing =
334
+ throw CraftNativeMutationFailure(code, message, index)
335
+ }