@elizaos/capacitor-canvas 1.0.0

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,2175 @@
1
+ package ai.eliza.plugins.canvas
2
+
3
+ import android.graphics.*
4
+ import android.os.Handler
5
+ import android.os.Looper
6
+ import android.util.Base64
7
+ import android.view.MotionEvent
8
+ import android.view.View
9
+ import android.view.ViewGroup
10
+ import android.webkit.JavascriptInterface
11
+ import android.webkit.WebChromeClient
12
+ import android.webkit.WebResourceError
13
+ import android.webkit.WebResourceRequest
14
+ import android.webkit.WebSettings
15
+ import android.webkit.WebView
16
+ import android.webkit.WebViewClient
17
+ import android.widget.FrameLayout
18
+ import com.getcapacitor.JSArray
19
+ import com.getcapacitor.JSObject
20
+ import com.getcapacitor.Plugin
21
+ import com.getcapacitor.PluginCall
22
+ import com.getcapacitor.PluginMethod
23
+ import com.getcapacitor.annotation.CapacitorPlugin
24
+ import org.json.JSONArray
25
+ import org.json.JSONObject
26
+ import java.io.ByteArrayOutputStream
27
+ import java.net.URL
28
+ import java.util.UUID
29
+ import kotlin.math.tan
30
+
31
+ @CapacitorPlugin(name = "ElizaCanvas")
32
+ class CanvasPlugin : Plugin() {
33
+
34
+ private val canvases = mutableMapOf<String, ManagedCanvas>()
35
+ private var nextCanvasId = 1
36
+ private var nextLayerId = 1
37
+
38
+ // ---- Data Structures ----
39
+
40
+ data class CanvasSize(val width: Int, val height: Int)
41
+
42
+ class ManagedCanvas(
43
+ val id: String,
44
+ var view: CanvasView,
45
+ var webView: WebView? = null,
46
+ var layers: MutableMap<String, ManagedLayer> = mutableMapOf(),
47
+ var size: CanvasSize,
48
+ var touchEnabled: Boolean = false,
49
+ var globalTransform: Matrix = Matrix()
50
+ )
51
+
52
+ data class ManagedLayer(
53
+ val id: String,
54
+ var name: String?,
55
+ var visible: Boolean,
56
+ var opacity: Float,
57
+ var zIndex: Int,
58
+ var view: CanvasView
59
+ )
60
+
61
+ // ---- CanvasView: a View backed by a Bitmap/Canvas ----
62
+
63
+ class CanvasView(context: android.content.Context, private var size: CanvasSize) :
64
+ View(context) {
65
+
66
+ private var bitmap: Bitmap =
67
+ Bitmap.createBitmap(size.width, size.height, Bitmap.Config.ARGB_8888)
68
+ private var drawCanvas: Canvas = Canvas(bitmap)
69
+ private val drawPaint = Paint()
70
+ var touchHandler: ((String, List<TouchInfo>) -> Unit)? = null
71
+
72
+ data class TouchInfo(
73
+ val id: Int, val x: Float, val y: Float, val pressure: Float?
74
+ )
75
+
76
+ override fun onDraw(canvas: Canvas) {
77
+ super.onDraw(canvas)
78
+ canvas.drawBitmap(bitmap, 0f, 0f, null)
79
+ }
80
+
81
+ fun getDrawCanvas(): Canvas = drawCanvas
82
+ fun getBitmap(): Bitmap = bitmap
83
+
84
+ fun clear(rect: RectF? = null) {
85
+ if (rect != null) {
86
+ drawPaint.xfermode = PorterDuffXfermode(PorterDuff.Mode.CLEAR)
87
+ drawCanvas.drawRect(rect, drawPaint)
88
+ drawPaint.xfermode = null
89
+ } else {
90
+ bitmap.eraseColor(Color.TRANSPARENT)
91
+ }
92
+ invalidate()
93
+ }
94
+
95
+ fun resize(newSize: CanvasSize) {
96
+ val newBitmap =
97
+ Bitmap.createBitmap(newSize.width, newSize.height, Bitmap.Config.ARGB_8888)
98
+ val newCanvas = Canvas(newBitmap)
99
+ newCanvas.drawBitmap(bitmap, 0f, 0f, null)
100
+ bitmap.recycle()
101
+ bitmap = newBitmap
102
+ drawCanvas = newCanvas
103
+ size = newSize
104
+ invalidate()
105
+ }
106
+
107
+ fun setImage(bmp: Bitmap?) {
108
+ if (bmp == null) {
109
+ bitmap.eraseColor(Color.TRANSPARENT)
110
+ } else {
111
+ drawCanvas.drawBitmap(bmp, 0f, 0f, null)
112
+ }
113
+ invalidate()
114
+ }
115
+
116
+ override fun onTouchEvent(event: MotionEvent): Boolean {
117
+ val type = when (event.actionMasked) {
118
+ MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> "start"
119
+ MotionEvent.ACTION_MOVE -> "move"
120
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> "end"
121
+ MotionEvent.ACTION_CANCEL -> "cancel"
122
+ else -> return super.onTouchEvent(event)
123
+ }
124
+
125
+ val touches = mutableListOf<TouchInfo>()
126
+ for (i in 0 until event.pointerCount) {
127
+ touches.add(
128
+ TouchInfo(
129
+ id = event.getPointerId(i),
130
+ x = event.getX(i),
131
+ y = event.getY(i),
132
+ pressure = if (event.getPressure(i) > 0) event.getPressure(i) else null
133
+ )
134
+ )
135
+ }
136
+
137
+ touchHandler?.invoke(type, touches)
138
+ return true
139
+ }
140
+
141
+ fun commit() {
142
+ invalidate()
143
+ }
144
+ }
145
+
146
+ // ---- Create / Destroy ----
147
+
148
+ @PluginMethod
149
+ fun create(call: PluginCall) {
150
+ val sizeObj = call.getObject("size") ?: run {
151
+ call.reject("Missing size parameter")
152
+ return
153
+ }
154
+
155
+ val width = sizeObj.int("width", 100)
156
+ val height = sizeObj.int("height", 100)
157
+ val size = CanvasSize(width, height)
158
+
159
+ val canvasId = "canvas_${nextCanvasId++}"
160
+
161
+ activity.runOnUiThread {
162
+ val view = CanvasView(context, size)
163
+ view.layoutParams = ViewGroup.LayoutParams(width, height)
164
+
165
+ val bgColorObj = call.getObject("backgroundColor")
166
+ if (bgColorObj != null) {
167
+ val color = colorFromObject(bgColorObj)
168
+ view.setBackgroundColor(color)
169
+ }
170
+
171
+ val canvas = ManagedCanvas(canvasId, view, size = size)
172
+ canvases[canvasId] = canvas
173
+
174
+ call.resolve(JSObject().apply {
175
+ put("canvasId", canvasId)
176
+ })
177
+ }
178
+ }
179
+
180
+ @PluginMethod
181
+ fun destroy(call: PluginCall) {
182
+ val canvasId = call.getString("canvasId") ?: run {
183
+ call.reject("Missing canvasId")
184
+ return
185
+ }
186
+
187
+ activity.runOnUiThread {
188
+ canvases[canvasId]?.let { canvas ->
189
+ canvas.webView?.let { wv ->
190
+ wv.destroy()
191
+ (wv.parent as? ViewGroup)?.removeView(wv)
192
+ }
193
+ (canvas.view.parent as? ViewGroup)?.removeView(canvas.view)
194
+ canvas.layers.values.forEach { layer ->
195
+ (layer.view.parent as? ViewGroup)?.removeView(layer.view)
196
+ }
197
+ }
198
+ canvases.remove(canvasId)
199
+ call.resolve()
200
+ }
201
+ }
202
+
203
+ // ---- Attach / Detach ----
204
+
205
+ @PluginMethod
206
+ fun attach(call: PluginCall) {
207
+ val canvasId = call.getString("canvasId") ?: run {
208
+ call.reject("Missing canvasId")
209
+ return
210
+ }
211
+
212
+ val canvas = canvases[canvasId] ?: run {
213
+ call.reject("Canvas not found")
214
+ return
215
+ }
216
+
217
+ activity.runOnUiThread {
218
+ val webView = bridge.webView
219
+ val parent = webView?.parent as? ViewGroup
220
+
221
+ if (parent != null) {
222
+ canvas.view.layoutParams = FrameLayout.LayoutParams(
223
+ FrameLayout.LayoutParams.MATCH_PARENT,
224
+ FrameLayout.LayoutParams.MATCH_PARENT
225
+ )
226
+
227
+ parent.addView(canvas.view, 0)
228
+ webView.setBackgroundColor(Color.TRANSPARENT)
229
+
230
+ // If a web canvas exists, ensure it's also in the hierarchy.
231
+ canvas.webView?.let { wv ->
232
+ if (wv.parent == null) {
233
+ wv.layoutParams = FrameLayout.LayoutParams(
234
+ FrameLayout.LayoutParams.MATCH_PARENT,
235
+ FrameLayout.LayoutParams.MATCH_PARENT
236
+ )
237
+ parent.addView(wv, 0)
238
+ }
239
+ }
240
+
241
+ // Set up touch handler.
242
+ canvas.view.touchHandler = { type, touches ->
243
+ if (canvas.touchEnabled) {
244
+ val touchArray = JSArray()
245
+ touches.forEach { touch ->
246
+ touchArray.put(JSObject().apply {
247
+ put("id", touch.id)
248
+ put("x", touch.x.toDouble())
249
+ put("y", touch.y.toDouble())
250
+ touch.pressure?.let { put("force", it.toDouble()) }
251
+ })
252
+ }
253
+
254
+ notifyListeners("touch", JSObject().apply {
255
+ put("type", type)
256
+ put("touches", touchArray)
257
+ put("timestamp", System.currentTimeMillis())
258
+ })
259
+ }
260
+ }
261
+ }
262
+
263
+ call.resolve()
264
+ }
265
+ }
266
+
267
+ @PluginMethod
268
+ fun detach(call: PluginCall) {
269
+ val canvasId = call.getString("canvasId") ?: run {
270
+ call.reject("Missing canvasId")
271
+ return
272
+ }
273
+
274
+ val canvas = canvases[canvasId] ?: run {
275
+ call.reject("Canvas not found")
276
+ return
277
+ }
278
+
279
+ activity.runOnUiThread {
280
+ (canvas.view.parent as? ViewGroup)?.removeView(canvas.view)
281
+ call.resolve()
282
+ }
283
+ }
284
+
285
+ // ---- Resize / Clear ----
286
+
287
+ @PluginMethod
288
+ fun resize(call: PluginCall) {
289
+ val canvasId = call.getString("canvasId") ?: run {
290
+ call.reject("Missing canvasId")
291
+ return
292
+ }
293
+
294
+ val sizeObj = call.getObject("size") ?: run {
295
+ call.reject("Missing size")
296
+ return
297
+ }
298
+
299
+ val canvas = canvases[canvasId] ?: run {
300
+ call.reject("Canvas not found")
301
+ return
302
+ }
303
+
304
+ val width = sizeObj.int("width", canvas.size.width)
305
+ val height = sizeObj.int("height", canvas.size.height)
306
+ val newSize = CanvasSize(width, height)
307
+
308
+ activity.runOnUiThread {
309
+ canvas.size = newSize
310
+ canvas.view.resize(newSize)
311
+ canvas.webView?.layoutParams =
312
+ FrameLayout.LayoutParams(width, height)
313
+ canvas.layers.values.forEach { it.view.resize(newSize) }
314
+ call.resolve()
315
+ }
316
+ }
317
+
318
+ @PluginMethod
319
+ fun clear(call: PluginCall) {
320
+ val canvasId = call.getString("canvasId") ?: run {
321
+ call.reject("Missing canvasId")
322
+ return
323
+ }
324
+
325
+ val canvas = canvases[canvasId] ?: run {
326
+ call.reject("Canvas not found")
327
+ return
328
+ }
329
+
330
+ val layerId = call.getString("layerId")
331
+ val rectObj = call.getObject("rect")
332
+
333
+ activity.runOnUiThread {
334
+ val targetView = layerId?.let { canvas.layers[it]?.view } ?: canvas.view
335
+
336
+ val rect = rectObj?.let {
337
+ RectF(
338
+ it.float("x"),
339
+ it.float("y"),
340
+ it.float("x") + it.float("width"),
341
+ it.float("y") + it.float("height")
342
+ )
343
+ }
344
+
345
+ targetView.clear(rect)
346
+ call.resolve()
347
+ }
348
+ }
349
+
350
+ // ---- Layer Operations ----
351
+
352
+ @PluginMethod
353
+ fun createLayer(call: PluginCall) {
354
+ val canvasId = call.getString("canvasId") ?: run {
355
+ call.reject("Missing canvasId")
356
+ return
357
+ }
358
+
359
+ val canvas = canvases[canvasId] ?: run {
360
+ call.reject("Canvas not found")
361
+ return
362
+ }
363
+
364
+ val layerObj = call.getObject("layer") ?: run {
365
+ call.reject("Missing layer")
366
+ return
367
+ }
368
+
369
+ val layerId = "layer_${nextLayerId++}"
370
+ val visible = layerObj.boolean("visible", true)
371
+ val opacity = layerObj.float("opacity", 1f)
372
+ val zIndex = layerObj.int("zIndex")
373
+ val name = layerObj.getString("name")
374
+
375
+ activity.runOnUiThread {
376
+ val view = CanvasView(context, canvas.size)
377
+ view.layoutParams = FrameLayout.LayoutParams(
378
+ FrameLayout.LayoutParams.MATCH_PARENT,
379
+ FrameLayout.LayoutParams.MATCH_PARENT
380
+ )
381
+ view.alpha = opacity
382
+ view.visibility = if (visible) View.VISIBLE else View.GONE
383
+
384
+ val layer = ManagedLayer(layerId, name, visible, opacity, zIndex, view)
385
+ canvas.layers[layerId] = layer
386
+
387
+ val parent = canvas.view.parent as? ViewGroup
388
+ parent?.addView(view)
389
+ sortLayers(canvas)
390
+
391
+ call.resolve(JSObject().apply {
392
+ put("layerId", layerId)
393
+ })
394
+ }
395
+ }
396
+
397
+ @PluginMethod
398
+ fun updateLayer(call: PluginCall) {
399
+ val canvasId = call.getString("canvasId") ?: run {
400
+ call.reject("Missing canvasId")
401
+ return
402
+ }
403
+ val layerId = call.getString("layerId") ?: run {
404
+ call.reject("Missing layerId")
405
+ return
406
+ }
407
+ val canvas = canvases[canvasId] ?: run {
408
+ call.reject("Canvas not found")
409
+ return
410
+ }
411
+ val layer = canvas.layers[layerId] ?: run {
412
+ call.reject("Layer not found")
413
+ return
414
+ }
415
+ val layerObj = call.getObject("layer") ?: run {
416
+ call.reject("Missing layer")
417
+ return
418
+ }
419
+
420
+ activity.runOnUiThread {
421
+ layerObj.booleanOrNull("visible")?.let {
422
+ layer.visible = it
423
+ layer.view.visibility = if (it) View.VISIBLE else View.GONE
424
+ }
425
+ layerObj.floatOrNull("opacity")?.let {
426
+ layer.opacity = it
427
+ layer.view.alpha = layer.opacity
428
+ }
429
+ layerObj.intOrNull("zIndex")?.let {
430
+ layer.zIndex = it
431
+ sortLayers(canvas)
432
+ }
433
+ layerObj.getString("name")?.let {
434
+ layer.name = it
435
+ }
436
+ call.resolve()
437
+ }
438
+ }
439
+
440
+ @PluginMethod
441
+ fun deleteLayer(call: PluginCall) {
442
+ val canvasId = call.getString("canvasId") ?: run {
443
+ call.reject("Missing canvasId")
444
+ return
445
+ }
446
+ val layerId = call.getString("layerId") ?: run {
447
+ call.reject("Missing layerId")
448
+ return
449
+ }
450
+ val canvas = canvases[canvasId] ?: run {
451
+ call.reject("Canvas not found")
452
+ return
453
+ }
454
+ val layer = canvas.layers[layerId] ?: run {
455
+ call.reject("Layer not found")
456
+ return
457
+ }
458
+
459
+ activity.runOnUiThread {
460
+ (layer.view.parent as? ViewGroup)?.removeView(layer.view)
461
+ canvas.layers.remove(layerId)
462
+ call.resolve()
463
+ }
464
+ }
465
+
466
+ @PluginMethod
467
+ fun getLayers(call: PluginCall) {
468
+ val canvasId = call.getString("canvasId") ?: run {
469
+ call.reject("Missing canvasId")
470
+ return
471
+ }
472
+ val canvas = canvases[canvasId] ?: run {
473
+ call.reject("Canvas not found")
474
+ return
475
+ }
476
+
477
+ val layers = JSArray()
478
+ canvas.layers.values.forEach { layer ->
479
+ layers.put(JSObject().apply {
480
+ put("id", layer.id)
481
+ put("visible", layer.visible)
482
+ put("opacity", layer.opacity.toDouble())
483
+ put("zIndex", layer.zIndex)
484
+ layer.name?.let { put("name", it) }
485
+ })
486
+ }
487
+
488
+ call.resolve(JSObject().apply {
489
+ put("layers", layers)
490
+ })
491
+ }
492
+
493
+ // ---- Drawing: Rect ----
494
+
495
+ @PluginMethod
496
+ fun drawRect(call: PluginCall) {
497
+ val canvasId = call.getString("canvasId") ?: run {
498
+ call.reject("Missing canvasId")
499
+ return
500
+ }
501
+ val canvas = canvases[canvasId] ?: run {
502
+ call.reject("Canvas not found")
503
+ return
504
+ }
505
+ val rectObj = call.getObject("rect") ?: run {
506
+ call.reject("Missing rect")
507
+ return
508
+ }
509
+
510
+ val drawOpts = call.getObject("drawOptions")
511
+ val layerId = drawOpts?.getString("layerId")
512
+ val fillObj = call.getObject("fill")
513
+ val strokeObj = call.getObject("stroke")
514
+ val cornerRadius = call.getFloat("cornerRadius") ?: 0f
515
+
516
+ activity.runOnUiThread {
517
+ val targetView = layerId?.let { canvas.layers[it]?.view } ?: canvas.view
518
+ val drawCanvas = targetView.getDrawCanvas()
519
+ val paint = Paint().apply { isAntiAlias = true }
520
+ val saveCount = applyDrawOptions(drawCanvas, canvas, drawOpts)
521
+
522
+ val rect = rectFromObject(rectObj)
523
+
524
+ fillObj?.let {
525
+ val gradient = extractGradient(it)
526
+ if (gradient != null) {
527
+ paint.shader = createShader(gradient)
528
+ paint.style = Paint.Style.FILL
529
+ if (cornerRadius > 0) {
530
+ drawCanvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint)
531
+ } else {
532
+ drawCanvas.drawRect(rect, paint)
533
+ }
534
+ paint.shader = null
535
+ } else {
536
+ paint.color = colorFromFillOrStroke(it)
537
+ paint.style = Paint.Style.FILL
538
+ if (cornerRadius > 0) {
539
+ drawCanvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint)
540
+ } else {
541
+ drawCanvas.drawRect(rect, paint)
542
+ }
543
+ }
544
+ }
545
+
546
+ strokeObj?.let {
547
+ paint.color = colorFromFillOrStroke(it)
548
+ paint.style = Paint.Style.STROKE
549
+ paint.strokeWidth = it.float("width", 1f)
550
+ applyStrokeStyle(paint, it)
551
+ if (cornerRadius > 0) {
552
+ drawCanvas.drawRoundRect(rect, cornerRadius, cornerRadius, paint)
553
+ } else {
554
+ drawCanvas.drawRect(rect, paint)
555
+ }
556
+ }
557
+
558
+ restoreDrawOptions(drawCanvas, saveCount)
559
+ targetView.commit()
560
+ call.resolve()
561
+ }
562
+ }
563
+
564
+ // ---- Drawing: Ellipse ----
565
+
566
+ @PluginMethod
567
+ fun drawEllipse(call: PluginCall) {
568
+ val canvasId = call.getString("canvasId") ?: run {
569
+ call.reject("Missing canvasId")
570
+ return
571
+ }
572
+ val canvas = canvases[canvasId] ?: run {
573
+ call.reject("Canvas not found")
574
+ return
575
+ }
576
+ val centerObj = call.getObject("center") ?: run {
577
+ call.reject("Missing center")
578
+ return
579
+ }
580
+
581
+ val radiusX = call.getFloat("radiusX") ?: 0f
582
+ val radiusY = call.getFloat("radiusY") ?: 0f
583
+ val drawOpts = call.getObject("drawOptions")
584
+ val layerId = drawOpts?.getString("layerId")
585
+ val fillObj = call.getObject("fill")
586
+ val strokeObj = call.getObject("stroke")
587
+
588
+ activity.runOnUiThread {
589
+ val targetView = layerId?.let { canvas.layers[it]?.view } ?: canvas.view
590
+ val drawCanvas = targetView.getDrawCanvas()
591
+ val paint = Paint().apply { isAntiAlias = true }
592
+ val saveCount = applyDrawOptions(drawCanvas, canvas, drawOpts)
593
+
594
+ val cx = centerObj.float("x")
595
+ val cy = centerObj.float("y")
596
+ val rect = RectF(cx - radiusX, cy - radiusY, cx + radiusX, cy + radiusY)
597
+
598
+ fillObj?.let {
599
+ val gradient = extractGradient(it)
600
+ if (gradient != null) {
601
+ paint.shader = createShader(gradient)
602
+ paint.style = Paint.Style.FILL
603
+ drawCanvas.drawOval(rect, paint)
604
+ paint.shader = null
605
+ } else {
606
+ paint.color = colorFromFillOrStroke(it)
607
+ paint.style = Paint.Style.FILL
608
+ drawCanvas.drawOval(rect, paint)
609
+ }
610
+ }
611
+
612
+ strokeObj?.let {
613
+ paint.color = colorFromFillOrStroke(it)
614
+ paint.style = Paint.Style.STROKE
615
+ paint.strokeWidth = it.float("width", 1f)
616
+ applyStrokeStyle(paint, it)
617
+ drawCanvas.drawOval(rect, paint)
618
+ }
619
+
620
+ restoreDrawOptions(drawCanvas, saveCount)
621
+ targetView.commit()
622
+ call.resolve()
623
+ }
624
+ }
625
+
626
+ // ---- Drawing: Line ----
627
+
628
+ @PluginMethod
629
+ fun drawLine(call: PluginCall) {
630
+ val canvasId = call.getString("canvasId") ?: run {
631
+ call.reject("Missing canvasId")
632
+ return
633
+ }
634
+ val canvas = canvases[canvasId] ?: run {
635
+ call.reject("Canvas not found")
636
+ return
637
+ }
638
+ val fromObj = call.getObject("from") ?: run {
639
+ call.reject("Missing from")
640
+ return
641
+ }
642
+ val toObj = call.getObject("to") ?: run {
643
+ call.reject("Missing to")
644
+ return
645
+ }
646
+ val strokeObj = call.getObject("stroke") ?: run {
647
+ call.reject("Missing stroke")
648
+ return
649
+ }
650
+ val drawOpts = call.getObject("drawOptions")
651
+ val layerId = drawOpts?.getString("layerId")
652
+
653
+ activity.runOnUiThread {
654
+ val targetView = layerId?.let { canvas.layers[it]?.view } ?: canvas.view
655
+ val drawCanvas = targetView.getDrawCanvas()
656
+ val saveCount = applyDrawOptions(drawCanvas, canvas, drawOpts)
657
+
658
+ val paint = Paint().apply {
659
+ isAntiAlias = true
660
+ style = Paint.Style.STROKE
661
+ color = colorFromFillOrStroke(strokeObj)
662
+ strokeWidth = strokeObj.float("width", 1f)
663
+ }
664
+ applyStrokeStyle(paint, strokeObj)
665
+
666
+ drawCanvas.drawLine(
667
+ fromObj.float("x"),
668
+ fromObj.float("y"),
669
+ toObj.float("x"),
670
+ toObj.float("y"),
671
+ paint
672
+ )
673
+
674
+ restoreDrawOptions(drawCanvas, saveCount)
675
+ targetView.commit()
676
+ call.resolve()
677
+ }
678
+ }
679
+
680
+ // ---- Drawing: Path ----
681
+
682
+ @PluginMethod
683
+ fun drawPath(call: PluginCall) {
684
+ val canvasId = call.getString("canvasId") ?: run {
685
+ call.reject("Missing canvasId")
686
+ return
687
+ }
688
+ val canvas = canvases[canvasId] ?: run {
689
+ call.reject("Canvas not found")
690
+ return
691
+ }
692
+ val pathObj = call.getObject("path") ?: run {
693
+ call.reject("Missing path")
694
+ return
695
+ }
696
+ val commands = pathObj.arrayOrNull("commands") ?: run {
697
+ call.reject("Missing commands in path")
698
+ return
699
+ }
700
+
701
+ val drawOpts = call.getObject("drawOptions")
702
+ val layerId = drawOpts?.getString("layerId")
703
+ val fillObj = call.getObject("fill")
704
+ val strokeObj = call.getObject("stroke")
705
+
706
+ activity.runOnUiThread {
707
+ val targetView = layerId?.let { canvas.layers[it]?.view } ?: canvas.view
708
+ val drawCanvas = targetView.getDrawCanvas()
709
+ val paint = Paint().apply { isAntiAlias = true }
710
+ val saveCount = applyDrawOptions(drawCanvas, canvas, drawOpts)
711
+
712
+ val path = buildPath(commands)
713
+
714
+ fillObj?.let {
715
+ val gradient = extractGradient(it)
716
+ if (gradient != null) {
717
+ paint.shader = createShader(gradient)
718
+ paint.style = Paint.Style.FILL
719
+ drawCanvas.drawPath(path, paint)
720
+ paint.shader = null
721
+ } else {
722
+ paint.color = colorFromFillOrStroke(it)
723
+ paint.style = Paint.Style.FILL
724
+ drawCanvas.drawPath(path, paint)
725
+ }
726
+ }
727
+
728
+ strokeObj?.let {
729
+ paint.color = colorFromFillOrStroke(it)
730
+ paint.style = Paint.Style.STROKE
731
+ paint.strokeWidth = it.float("width", 1f)
732
+ applyStrokeStyle(paint, it)
733
+ drawCanvas.drawPath(path, paint)
734
+ }
735
+
736
+ restoreDrawOptions(drawCanvas, saveCount)
737
+ targetView.commit()
738
+ call.resolve()
739
+ }
740
+ }
741
+
742
+ /** Build an Android Path from the CanvasDrawPathCommand array. */
743
+ private fun buildPath(commands: JSONArray): Path {
744
+ val path = Path()
745
+ for (i in 0 until commands.length()) {
746
+ val cmd = commands.getJSONObject(i)
747
+ val type = cmd.optString("type", "")
748
+ val args = cmd.optJSONArray("args") ?: JSONArray()
749
+ val a = { idx: Int -> args.optDouble(idx, 0.0).toFloat() }
750
+
751
+ when (type) {
752
+ "moveTo" -> if (args.length() >= 2) {
753
+ path.moveTo(a(0), a(1))
754
+ }
755
+ "lineTo" -> if (args.length() >= 2) {
756
+ path.lineTo(a(0), a(1))
757
+ }
758
+ "quadraticCurveTo" -> if (args.length() >= 4) {
759
+ path.quadTo(a(0), a(1), a(2), a(3))
760
+ }
761
+ "bezierCurveTo" -> if (args.length() >= 6) {
762
+ path.cubicTo(a(0), a(1), a(2), a(3), a(4), a(5))
763
+ }
764
+ "arcTo" -> if (args.length() >= 5) {
765
+ // arcTo(x1, y1, x2, y2, radius) -- approximate with cubicTo.
766
+ // Android Path doesn't have tangent arc; use addArc as approximation.
767
+ val radius = a(4)
768
+ val oval = RectF(
769
+ a(0) - radius, a(1) - radius,
770
+ a(0) + radius, a(1) + radius
771
+ )
772
+ path.arcTo(oval, 0f, 90f)
773
+ }
774
+ "arc" -> if (args.length() >= 5) {
775
+ val cx = a(0)
776
+ val cy = a(1)
777
+ val radius = a(2)
778
+ val startAngle = Math.toDegrees(a(3).toDouble()).toFloat()
779
+ val endAngle = Math.toDegrees(a(4).toDouble()).toFloat()
780
+ val counterclockwise =
781
+ args.length() > 5 && args.optDouble(5, 0.0) != 0.0
782
+ val sweep = if (counterclockwise) {
783
+ -(((startAngle - endAngle) % 360 + 360) % 360)
784
+ } else {
785
+ (((endAngle - startAngle) % 360 + 360) % 360)
786
+ }
787
+ val oval = RectF(
788
+ cx - radius, cy - radius, cx + radius, cy + radius
789
+ )
790
+ path.arcTo(oval, startAngle, sweep)
791
+ }
792
+ "ellipse" -> if (args.length() >= 7) {
793
+ val cx = a(0)
794
+ val cy = a(1)
795
+ val rx = a(2)
796
+ val ry = a(3)
797
+ val rotation = a(4)
798
+ val startAngle = Math.toDegrees(a(5).toDouble()).toFloat()
799
+ val endAngle = Math.toDegrees(a(6).toDouble()).toFloat()
800
+ val counterclockwise =
801
+ args.length() > 7 && args.optDouble(7, 0.0) != 0.0
802
+ val sweep = if (counterclockwise) {
803
+ -(((startAngle - endAngle) % 360 + 360) % 360)
804
+ } else {
805
+ (((endAngle - startAngle) % 360 + 360) % 360)
806
+ }
807
+ val m = Matrix()
808
+ m.postTranslate(-cx, -cy)
809
+ m.postRotate(Math.toDegrees(rotation.toDouble()).toFloat())
810
+ m.postTranslate(cx, cy)
811
+ val subPath = Path()
812
+ val oval = RectF(cx - rx, cy - ry, cx + rx, cy + ry)
813
+ subPath.arcTo(oval, startAngle, sweep)
814
+ subPath.transform(m)
815
+ path.addPath(subPath)
816
+ }
817
+ "rect" -> if (args.length() >= 4) {
818
+ path.addRect(
819
+ a(0), a(1), a(0) + a(2), a(1) + a(3),
820
+ Path.Direction.CW
821
+ )
822
+ }
823
+ "closePath" -> path.close()
824
+ }
825
+ }
826
+ return path
827
+ }
828
+
829
+ // ---- Drawing: Text ----
830
+
831
+ @PluginMethod
832
+ fun drawText(call: PluginCall) {
833
+ val canvasId = call.getString("canvasId") ?: run {
834
+ call.reject("Missing canvasId")
835
+ return
836
+ }
837
+ val canvas = canvases[canvasId] ?: run {
838
+ call.reject("Canvas not found")
839
+ return
840
+ }
841
+ val text = call.getString("text") ?: run {
842
+ call.reject("Missing text")
843
+ return
844
+ }
845
+ val positionObj = call.getObject("position") ?: run {
846
+ call.reject("Missing position")
847
+ return
848
+ }
849
+ val styleObj = call.getObject("style") ?: run {
850
+ call.reject("Missing style")
851
+ return
852
+ }
853
+ val drawOpts = call.getObject("drawOptions")
854
+ val layerId = drawOpts?.getString("layerId")
855
+
856
+ activity.runOnUiThread {
857
+ val targetView = layerId?.let { canvas.layers[it]?.view } ?: canvas.view
858
+ val drawCanvas = targetView.getDrawCanvas()
859
+ val saveCount = applyDrawOptions(drawCanvas, canvas, drawOpts)
860
+
861
+ val fontSize = styleObj.float("size", 14f)
862
+ val fontName = styleObj.getString("font") ?: "sans-serif"
863
+ val align = styleObj.getString("align") ?: "left"
864
+ val baseline = styleObj.getString("baseline") ?: "alphabetic"
865
+ val maxWidth = styleObj.floatOrNull("maxWidth")
866
+
867
+ val typeface = try {
868
+ Typeface.create(fontName, Typeface.NORMAL)
869
+ } catch (_: Exception) {
870
+ Typeface.DEFAULT
871
+ }
872
+
873
+ val paint = Paint().apply {
874
+ isAntiAlias = true
875
+ textSize = fontSize
876
+ this.typeface = typeface
877
+ color = colorFromFillOrStroke(styleObj)
878
+ textAlign = when (align) {
879
+ "center" -> Paint.Align.CENTER
880
+ "right" -> Paint.Align.RIGHT
881
+ else -> Paint.Align.LEFT
882
+ }
883
+ }
884
+
885
+ var x = positionObj.float("x")
886
+ var y = positionObj.float("y")
887
+
888
+ // Adjust for baseline.
889
+ val metrics = paint.fontMetrics
890
+ when (baseline) {
891
+ "top" -> y -= metrics.top
892
+ "middle" -> y -= (metrics.top + metrics.bottom) / 2
893
+ "bottom" -> y -= metrics.bottom
894
+ // "alphabetic" is the default baseline for drawText.
895
+ }
896
+
897
+ if (maxWidth != null) {
898
+ // Scale text to fit within maxWidth.
899
+ val textWidth = paint.measureText(text)
900
+ if (textWidth > maxWidth) {
901
+ paint.textScaleX = maxWidth / textWidth
902
+ }
903
+ }
904
+
905
+ drawCanvas.drawText(text, x, y, paint)
906
+
907
+ restoreDrawOptions(drawCanvas, saveCount)
908
+ targetView.commit()
909
+ call.resolve()
910
+ }
911
+ }
912
+
913
+ // ---- Drawing: Image ----
914
+
915
+ @PluginMethod
916
+ fun drawImage(call: PluginCall) {
917
+ val canvasId = call.getString("canvasId") ?: run {
918
+ call.reject("Missing canvasId")
919
+ return
920
+ }
921
+ val canvas = canvases[canvasId] ?: run {
922
+ call.reject("Canvas not found")
923
+ return
924
+ }
925
+ val destRectObj = call.getObject("destRect") ?: run {
926
+ call.reject("Missing destRect")
927
+ return
928
+ }
929
+
930
+ val drawOpts = call.getObject("drawOptions")
931
+ val layerId = drawOpts?.getString("layerId")
932
+ val imageObj = call.getObject("image")
933
+ val imageString = call.getString("image")
934
+ val srcRectObj = call.getObject("srcRect")
935
+
936
+ activity.runOnUiThread {
937
+ val targetView = layerId?.let { canvas.layers[it]?.view } ?: canvas.view
938
+ val drawCanvas = targetView.getDrawCanvas()
939
+ val saveCount = applyDrawOptions(drawCanvas, canvas, drawOpts)
940
+
941
+ var bitmap: Bitmap? = null
942
+
943
+ // Try to decode from base64 object.
944
+ if (imageObj != null) {
945
+ val base64 = imageObj.getString("base64")
946
+ if (base64 != null) {
947
+ try {
948
+ val bytes = Base64.decode(base64, Base64.DEFAULT)
949
+ bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
950
+ } catch (_: Exception) {
951
+ }
952
+ }
953
+ }
954
+
955
+ // Try to load from URL string (only for local/data URIs on main thread).
956
+ if (bitmap == null && imageString != null) {
957
+ try {
958
+ if (imageString.startsWith("data:")) {
959
+ val commaIdx = imageString.indexOf(',')
960
+ if (commaIdx > 0) {
961
+ val base64Data = imageString.substring(commaIdx + 1)
962
+ val bytes = Base64.decode(base64Data, Base64.DEFAULT)
963
+ bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
964
+ }
965
+ }
966
+ } catch (_: Exception) {
967
+ }
968
+ }
969
+
970
+ if (bitmap != null) {
971
+ val destRect = rectFromObject(destRectObj)
972
+
973
+ if (srcRectObj != null) {
974
+ // Crop source bitmap then draw into dest.
975
+ val srcRect = Rect(
976
+ srcRectObj.int("x"),
977
+ srcRectObj.int("y"),
978
+ (srcRectObj.double("x") + srcRectObj.double("width")).toInt(),
979
+ (srcRectObj.double("y") + srcRectObj.double("height")).toInt()
980
+ )
981
+ val dst = Rect(
982
+ destRect.left.toInt(), destRect.top.toInt(),
983
+ destRect.right.toInt(), destRect.bottom.toInt()
984
+ )
985
+ drawCanvas.drawBitmap(bitmap, srcRect, dst, null)
986
+ } else {
987
+ val dst = Rect(
988
+ destRect.left.toInt(), destRect.top.toInt(),
989
+ destRect.right.toInt(), destRect.bottom.toInt()
990
+ )
991
+ drawCanvas.drawBitmap(bitmap, null, dst, null)
992
+ }
993
+
994
+ bitmap.recycle()
995
+ }
996
+
997
+ restoreDrawOptions(drawCanvas, saveCount)
998
+ targetView.commit()
999
+ call.resolve()
1000
+ }
1001
+ }
1002
+
1003
+ // ---- Drawing: Batch ----
1004
+
1005
+ @PluginMethod
1006
+ fun drawBatch(call: PluginCall) {
1007
+ val canvasId = call.getString("canvasId") ?: run {
1008
+ call.reject("Missing canvasId")
1009
+ return
1010
+ }
1011
+ val canvas = canvases[canvasId] ?: run {
1012
+ call.reject("Canvas not found")
1013
+ return
1014
+ }
1015
+ val commands = call.getArray("commands") ?: run {
1016
+ call.reject("Missing commands")
1017
+ return
1018
+ }
1019
+
1020
+ activity.runOnUiThread {
1021
+ val paint = Paint().apply { isAntiAlias = true }
1022
+
1023
+ for (i in 0 until commands.length()) {
1024
+ val command = commands.getJSONObject(i) ?: continue
1025
+ val type = command.optString("type", "")
1026
+ val args = command.optJSONObject("args") ?: continue
1027
+
1028
+ val drawOptsObj = args.optJSONObject("drawOptions")
1029
+ val targetLayerId = drawOptsObj?.optString("layerId")
1030
+ val targetView =
1031
+ targetLayerId?.let { canvas.layers[it]?.view } ?: canvas.view
1032
+ val drawCanvas = targetView.getDrawCanvas()
1033
+ val drawOpts = drawOptsObj?.let { jsObjectFromJSON(it) }
1034
+ val saveCount = applyDrawOptions(drawCanvas, canvas, drawOpts)
1035
+
1036
+ when (type) {
1037
+ "rect" -> {
1038
+ val rectJson = args.optJSONObject("rect")
1039
+ if (rectJson != null) {
1040
+ val rect = rectFromJSON(rectJson)
1041
+ val cr = args.optDouble("cornerRadius", 0.0).toFloat()
1042
+ val fillJson = args.optJSONObject("fill")
1043
+ val strokeJson = args.optJSONObject("stroke")
1044
+
1045
+ fillJson?.let {
1046
+ val fillObj = jsObjectFromJSON(it)
1047
+ val gradient = extractGradient(fillObj)
1048
+ if (gradient != null) {
1049
+ paint.shader = createShader(gradient)
1050
+ } else {
1051
+ paint.shader = null
1052
+ paint.color = colorFromFillOrStroke(fillObj)
1053
+ }
1054
+ paint.style = Paint.Style.FILL
1055
+ if (cr > 0) drawCanvas.drawRoundRect(rect, cr, cr, paint)
1056
+ else drawCanvas.drawRect(rect, paint)
1057
+ paint.shader = null
1058
+ }
1059
+ strokeJson?.let {
1060
+ val strokeObj = jsObjectFromJSON(it)
1061
+ paint.color = colorFromFillOrStroke(strokeObj)
1062
+ paint.style = Paint.Style.STROKE
1063
+ paint.strokeWidth =
1064
+ strokeObj.float("width", 1f)
1065
+ applyStrokeStyle(paint, strokeObj)
1066
+ if (cr > 0) drawCanvas.drawRoundRect(rect, cr, cr, paint)
1067
+ else drawCanvas.drawRect(rect, paint)
1068
+ }
1069
+ }
1070
+ }
1071
+ "ellipse" -> {
1072
+ val centerJson = args.optJSONObject("center")
1073
+ if (centerJson != null) {
1074
+ val cx = centerJson.optDouble("x", 0.0).toFloat()
1075
+ val cy = centerJson.optDouble("y", 0.0).toFloat()
1076
+ val rx = args.optDouble("radiusX", 0.0).toFloat()
1077
+ val ry = args.optDouble("radiusY", 0.0).toFloat()
1078
+ val ellRect =
1079
+ RectF(cx - rx, cy - ry, cx + rx, cy + ry)
1080
+
1081
+ args.optJSONObject("fill")?.let {
1082
+ val fillObj = jsObjectFromJSON(it)
1083
+ val gradient = extractGradient(fillObj)
1084
+ if (gradient != null) {
1085
+ paint.shader = createShader(gradient)
1086
+ } else {
1087
+ paint.shader = null
1088
+ paint.color = colorFromFillOrStroke(fillObj)
1089
+ }
1090
+ paint.style = Paint.Style.FILL
1091
+ drawCanvas.drawOval(ellRect, paint)
1092
+ paint.shader = null
1093
+ }
1094
+ args.optJSONObject("stroke")?.let {
1095
+ val strokeObj = jsObjectFromJSON(it)
1096
+ paint.color = colorFromFillOrStroke(strokeObj)
1097
+ paint.style = Paint.Style.STROKE
1098
+ paint.strokeWidth =
1099
+ strokeObj.float("width", 1f)
1100
+ applyStrokeStyle(paint, strokeObj)
1101
+ drawCanvas.drawOval(ellRect, paint)
1102
+ }
1103
+ }
1104
+ }
1105
+ "line" -> {
1106
+ val fromJson = args.optJSONObject("from")
1107
+ val toJson = args.optJSONObject("to")
1108
+ val strokeJson = args.optJSONObject("stroke")
1109
+ if (fromJson != null && toJson != null && strokeJson != null) {
1110
+ val strokeObj = jsObjectFromJSON(strokeJson)
1111
+ paint.color = colorFromFillOrStroke(strokeObj)
1112
+ paint.style = Paint.Style.STROKE
1113
+ paint.strokeWidth =
1114
+ strokeObj.float("width", 1f)
1115
+ applyStrokeStyle(paint, strokeObj)
1116
+ drawCanvas.drawLine(
1117
+ fromJson.optDouble("x", 0.0).toFloat(),
1118
+ fromJson.optDouble("y", 0.0).toFloat(),
1119
+ toJson.optDouble("x", 0.0).toFloat(),
1120
+ toJson.optDouble("y", 0.0).toFloat(),
1121
+ paint
1122
+ )
1123
+ }
1124
+ }
1125
+ "path" -> {
1126
+ val pathJson = args.optJSONObject("path")
1127
+ val pathCommands = pathJson?.optJSONArray("commands")
1128
+ if (pathCommands != null) {
1129
+ val androidPath = buildPath(pathCommands)
1130
+ args.optJSONObject("fill")?.let {
1131
+ val fillObj = jsObjectFromJSON(it)
1132
+ val gradient = extractGradient(fillObj)
1133
+ if (gradient != null) {
1134
+ paint.shader = createShader(gradient)
1135
+ } else {
1136
+ paint.shader = null
1137
+ paint.color = colorFromFillOrStroke(fillObj)
1138
+ }
1139
+ paint.style = Paint.Style.FILL
1140
+ drawCanvas.drawPath(androidPath, paint)
1141
+ paint.shader = null
1142
+ }
1143
+ args.optJSONObject("stroke")?.let {
1144
+ val strokeObj = jsObjectFromJSON(it)
1145
+ paint.color = colorFromFillOrStroke(strokeObj)
1146
+ paint.style = Paint.Style.STROKE
1147
+ paint.strokeWidth =
1148
+ strokeObj.float("width", 1f)
1149
+ applyStrokeStyle(paint, strokeObj)
1150
+ drawCanvas.drawPath(androidPath, paint)
1151
+ }
1152
+ }
1153
+ }
1154
+ "text" -> {
1155
+ val textStr = args.optString("text", "")
1156
+ val posJson = args.optJSONObject("position")
1157
+ val styleJson = args.optJSONObject("style")
1158
+ if (textStr.isNotEmpty() && posJson != null && styleJson != null) {
1159
+ val styleObj = jsObjectFromJSON(styleJson)
1160
+ val textPaint = Paint().apply {
1161
+ isAntiAlias = true
1162
+ textSize = styleObj.float("size", 14f)
1163
+ color = colorFromFillOrStroke(styleObj)
1164
+ textAlign = when (styleObj.getString("align")) {
1165
+ "center" -> Paint.Align.CENTER
1166
+ "right" -> Paint.Align.RIGHT
1167
+ else -> Paint.Align.LEFT
1168
+ }
1169
+ }
1170
+ drawCanvas.drawText(
1171
+ textStr,
1172
+ posJson.optDouble("x", 0.0).toFloat(),
1173
+ posJson.optDouble("y", 0.0).toFloat(),
1174
+ textPaint
1175
+ )
1176
+ }
1177
+ }
1178
+ "image" -> {
1179
+ val destRectJson = args.optJSONObject("destRect")
1180
+ if (destRectJson != null) {
1181
+ val destRect = rectFromJSON(destRectJson)
1182
+ var bmp: Bitmap? = null
1183
+ val imgObj = args.optJSONObject("image")
1184
+ if (imgObj != null) {
1185
+ val b64 = imgObj.optString("base64", "")
1186
+ if (b64.isNotEmpty()) {
1187
+ try {
1188
+ val bytes = Base64.decode(b64, Base64.DEFAULT)
1189
+ bmp = BitmapFactory.decodeByteArray(
1190
+ bytes, 0, bytes.size
1191
+ )
1192
+ } catch (_: Exception) {
1193
+ }
1194
+ }
1195
+ }
1196
+ if (bmp != null) {
1197
+ val dst = Rect(
1198
+ destRect.left.toInt(), destRect.top.toInt(),
1199
+ destRect.right.toInt(), destRect.bottom.toInt()
1200
+ )
1201
+ drawCanvas.drawBitmap(bmp, null, dst, null)
1202
+ bmp.recycle()
1203
+ }
1204
+ }
1205
+ }
1206
+ "clear" -> {
1207
+ val clearRectJson = args.optJSONObject("rect")
1208
+ val clearLayerId = args.stringOrNull("layerId")
1209
+ val clearView =
1210
+ clearLayerId?.let { canvas.layers[it]?.view } ?: targetView
1211
+ if (clearRectJson != null) {
1212
+ clearView.clear(rectFromJSON(clearRectJson))
1213
+ } else {
1214
+ clearView.clear()
1215
+ }
1216
+ }
1217
+ }
1218
+
1219
+ restoreDrawOptions(drawCanvas, saveCount)
1220
+ targetView.commit()
1221
+ }
1222
+
1223
+ call.resolve()
1224
+ }
1225
+ }
1226
+
1227
+ // ---- Pixel Data / Export ----
1228
+
1229
+ @PluginMethod
1230
+ fun getPixelData(call: PluginCall) {
1231
+ val canvasId = call.getString("canvasId") ?: run {
1232
+ call.reject("Missing canvasId")
1233
+ return
1234
+ }
1235
+ val canvas = canvases[canvasId] ?: run {
1236
+ call.reject("Canvas not found")
1237
+ return
1238
+ }
1239
+ val rectObj = call.getObject("rect")
1240
+
1241
+ activity.runOnUiThread {
1242
+ val bitmap = canvas.view.getBitmap()
1243
+
1244
+ val region = if (rectObj != null) {
1245
+ val x = rectObj.int("x")
1246
+ val y = rectObj.int("y")
1247
+ val w = rectObj.int("width", bitmap.width)
1248
+ val h = rectObj.int("height", bitmap.height)
1249
+ Rect(x, y, (x + w).coerceAtMost(bitmap.width), (y + h).coerceAtMost(bitmap.height))
1250
+ } else {
1251
+ Rect(0, 0, bitmap.width, bitmap.height)
1252
+ }
1253
+
1254
+ val w = region.width()
1255
+ val h = region.height()
1256
+ if (w <= 0 || h <= 0) {
1257
+ call.reject("Invalid dimensions")
1258
+ return@runOnUiThread
1259
+ }
1260
+
1261
+ // Extract RGBA pixel data.
1262
+ val pixels = IntArray(w * h)
1263
+ bitmap.getPixels(pixels, 0, w, region.left, region.top, w, h)
1264
+
1265
+ // Convert ARGB int array to RGBA byte array.
1266
+ val rgba = ByteArray(w * h * 4)
1267
+ for (i in pixels.indices) {
1268
+ val pixel = pixels[i]
1269
+ val offset = i * 4
1270
+ rgba[offset] = ((pixel shr 16) and 0xFF).toByte() // R
1271
+ rgba[offset + 1] = ((pixel shr 8) and 0xFF).toByte() // G
1272
+ rgba[offset + 2] = (pixel and 0xFF).toByte() // B
1273
+ rgba[offset + 3] = ((pixel shr 24) and 0xFF).toByte() // A
1274
+ }
1275
+
1276
+ val base64 = Base64.encodeToString(rgba, Base64.NO_WRAP)
1277
+
1278
+ call.resolve(JSObject().apply {
1279
+ put("data", base64)
1280
+ put("width", w)
1281
+ put("height", h)
1282
+ })
1283
+ }
1284
+ }
1285
+
1286
+ @PluginMethod
1287
+ fun toImage(call: PluginCall) {
1288
+ val canvasId = call.getString("canvasId") ?: run {
1289
+ call.reject("Missing canvasId")
1290
+ return
1291
+ }
1292
+ val canvas = canvases[canvasId] ?: run {
1293
+ call.reject("Canvas not found")
1294
+ return
1295
+ }
1296
+
1297
+ val format = call.getString("format") ?: "png"
1298
+ val quality = call.getInt("quality") ?: 100
1299
+ val layerIds = call.getArray("layerIds")
1300
+
1301
+ activity.runOnUiThread {
1302
+ val bitmap: Bitmap
1303
+
1304
+ if (layerIds != null && layerIds.length() > 0) {
1305
+ // Composite only specified layers.
1306
+ bitmap = Bitmap.createBitmap(
1307
+ canvas.size.width, canvas.size.height, Bitmap.Config.ARGB_8888
1308
+ )
1309
+ val compositeCanvas = Canvas(bitmap)
1310
+ for (i in 0 until layerIds.length()) {
1311
+ val lid = layerIds.getString(i)
1312
+ canvas.layers[lid]?.let { layer ->
1313
+ compositeCanvas.drawBitmap(layer.view.getBitmap(), 0f, 0f, null)
1314
+ }
1315
+ }
1316
+ } else {
1317
+ // Draw the main canvas view plus all layers.
1318
+ bitmap = Bitmap.createBitmap(
1319
+ canvas.size.width, canvas.size.height, Bitmap.Config.ARGB_8888
1320
+ )
1321
+ val compositeCanvas = Canvas(bitmap)
1322
+ compositeCanvas.drawBitmap(canvas.view.getBitmap(), 0f, 0f, null)
1323
+ canvas.layers.values.sortedBy { it.zIndex }.forEach { layer ->
1324
+ if (layer.visible) {
1325
+ val layerPaint = Paint().apply { alpha = (layer.opacity * 255).toInt() }
1326
+ compositeCanvas.drawBitmap(
1327
+ layer.view.getBitmap(), 0f, 0f, layerPaint
1328
+ )
1329
+ }
1330
+ }
1331
+ }
1332
+
1333
+ val outputStream = ByteArrayOutputStream()
1334
+ val compressFormat = when (format) {
1335
+ "jpeg" -> Bitmap.CompressFormat.JPEG
1336
+ "webp" -> if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
1337
+ Bitmap.CompressFormat.WEBP_LOSSY
1338
+ } else {
1339
+ @Suppress("DEPRECATION")
1340
+ Bitmap.CompressFormat.WEBP
1341
+ }
1342
+ else -> Bitmap.CompressFormat.PNG
1343
+ }
1344
+
1345
+ bitmap.compress(compressFormat, quality, outputStream)
1346
+ val base64 = Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
1347
+ val outputFormat = if (format == "webp" &&
1348
+ android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.R
1349
+ ) "png" else format
1350
+
1351
+ bitmap.recycle()
1352
+
1353
+ call.resolve(JSObject().apply {
1354
+ put("base64", base64)
1355
+ put("format", outputFormat)
1356
+ put("width", canvas.size.width)
1357
+ put("height", canvas.size.height)
1358
+ })
1359
+ }
1360
+ }
1361
+
1362
+ // ---- Transform ----
1363
+
1364
+ @PluginMethod
1365
+ fun setTransform(call: PluginCall) {
1366
+ val canvasId = call.getString("canvasId") ?: run {
1367
+ call.reject("Missing canvasId")
1368
+ return
1369
+ }
1370
+ val canvas = canvases[canvasId] ?: run {
1371
+ call.reject("Canvas not found")
1372
+ return
1373
+ }
1374
+ val transformObj = call.getObject("transform") ?: run {
1375
+ call.reject("Missing transform")
1376
+ return
1377
+ }
1378
+
1379
+ canvas.globalTransform = matrixFromTransformObject(transformObj)
1380
+ call.resolve()
1381
+ }
1382
+
1383
+ @PluginMethod
1384
+ fun resetTransform(call: PluginCall) {
1385
+ val canvasId = call.getString("canvasId") ?: run {
1386
+ call.reject("Missing canvasId")
1387
+ return
1388
+ }
1389
+ val canvas = canvases[canvasId] ?: run {
1390
+ call.reject("Canvas not found")
1391
+ return
1392
+ }
1393
+
1394
+ canvas.globalTransform = Matrix()
1395
+ call.resolve()
1396
+ }
1397
+
1398
+ // ---- Touch ----
1399
+
1400
+ @PluginMethod
1401
+ fun setTouchEnabled(call: PluginCall) {
1402
+ val canvasId = call.getString("canvasId") ?: run {
1403
+ call.reject("Missing canvasId")
1404
+ return
1405
+ }
1406
+ val enabled = call.getBoolean("enabled") ?: false
1407
+ val canvas = canvases[canvasId] ?: run {
1408
+ call.reject("Canvas not found")
1409
+ return
1410
+ }
1411
+
1412
+ activity.runOnUiThread {
1413
+ canvas.touchEnabled = enabled
1414
+ canvas.view.isClickable = enabled
1415
+ call.resolve()
1416
+ }
1417
+ }
1418
+
1419
+ // ======== Web Canvas Operations ========
1420
+
1421
+ // ---- Navigate ----
1422
+
1423
+ @PluginMethod
1424
+ fun navigate(call: PluginCall) {
1425
+ val canvasId = call.getString("canvasId") ?: run {
1426
+ call.reject("Missing canvasId")
1427
+ return
1428
+ }
1429
+ val canvas = canvases[canvasId] ?: run {
1430
+ call.reject("Canvas not found")
1431
+ return
1432
+ }
1433
+ val urlString = call.getString("url") ?: run {
1434
+ call.reject("Missing url")
1435
+ return
1436
+ }
1437
+ val placementObj = call.getObject("placement")
1438
+
1439
+ activity.runOnUiThread {
1440
+ val wv = ensureWebView(canvas)
1441
+
1442
+ // Apply placement if provided, otherwise fill the canvas.
1443
+ if (placementObj != null) {
1444
+ val x = placementObj.float("x")
1445
+ val y = placementObj.float("y")
1446
+ val w = placementObj.float("width", canvas.size.width.toFloat())
1447
+ val h = placementObj.float("height", canvas.size.height.toFloat())
1448
+ wv.x = x
1449
+ wv.y = y
1450
+ wv.layoutParams = FrameLayout.LayoutParams(w.toInt(), h.toInt())
1451
+ }
1452
+
1453
+ val trimmed = urlString.trim()
1454
+ wv.loadUrl(trimmed)
1455
+
1456
+ call.resolve(JSObject().apply {
1457
+ put("url", trimmed)
1458
+ })
1459
+ }
1460
+ }
1461
+
1462
+ // ---- Eval ----
1463
+
1464
+ @PluginMethod
1465
+ fun eval(call: PluginCall) {
1466
+ val canvasId = call.getString("canvasId") ?: run {
1467
+ call.reject("Missing canvasId")
1468
+ return
1469
+ }
1470
+ val canvas = canvases[canvasId] ?: run {
1471
+ call.reject("Canvas not found")
1472
+ return
1473
+ }
1474
+ val script = call.getString("script") ?: run {
1475
+ call.reject("Missing script")
1476
+ return
1477
+ }
1478
+
1479
+ val wv = canvas.webView ?: run {
1480
+ call.reject("No web view - call navigate() first")
1481
+ return
1482
+ }
1483
+
1484
+ activity.runOnUiThread {
1485
+ wv.evaluateJavascript(script) { result ->
1486
+ call.resolve(JSObject().apply {
1487
+ put("result", result ?: "")
1488
+ })
1489
+ }
1490
+ }
1491
+ }
1492
+
1493
+ // ---- Snapshot ----
1494
+
1495
+ @PluginMethod
1496
+ fun snapshot(call: PluginCall) {
1497
+ val canvasId = call.getString("canvasId") ?: run {
1498
+ call.reject("Missing canvasId")
1499
+ return
1500
+ }
1501
+ val canvas = canvases[canvasId] ?: run {
1502
+ call.reject("Canvas not found")
1503
+ return
1504
+ }
1505
+
1506
+ val wv = canvas.webView ?: run {
1507
+ call.reject("No web view - call navigate() first")
1508
+ return
1509
+ }
1510
+
1511
+ val maxWidth = call.getFloat("maxWidth")
1512
+ val quality = call.getDouble("quality") ?: 0.82
1513
+ val formatStr = call.getString("format") ?: "png"
1514
+
1515
+ activity.runOnUiThread {
1516
+ // Capture the WebView as a bitmap (same approach as classic CanvasController).
1517
+ val width = wv.width.coerceAtLeast(1)
1518
+ val height = wv.height.coerceAtLeast(1)
1519
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
1520
+ val bitmapCanvas = Canvas(bitmap)
1521
+ wv.draw(bitmapCanvas)
1522
+
1523
+ // Scale if maxWidth specified.
1524
+ val scaled = if (maxWidth != null && maxWidth > 0 && bitmap.width > maxWidth) {
1525
+ val scale = maxWidth / bitmap.width
1526
+ val newH = (bitmap.height * scale).toInt().coerceAtLeast(1)
1527
+ Bitmap.createScaledBitmap(bitmap, maxWidth.toInt(), newH, true).also {
1528
+ if (it !== bitmap) bitmap.recycle()
1529
+ }
1530
+ } else {
1531
+ bitmap
1532
+ }
1533
+
1534
+ val outputStream = ByteArrayOutputStream()
1535
+ val (compressFormat, compressQuality) = when (formatStr) {
1536
+ "jpeg" -> Bitmap.CompressFormat.JPEG to (quality * 100).toInt()
1537
+ .coerceIn(1, 100)
1538
+ else -> Bitmap.CompressFormat.PNG to 100
1539
+ }
1540
+ scaled.compress(compressFormat, compressQuality, outputStream)
1541
+ val base64 = Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
1542
+ val outputFormat = if (formatStr == "jpeg") "jpeg" else "png"
1543
+
1544
+ val resultWidth = scaled.width
1545
+ val resultHeight = scaled.height
1546
+ if (scaled !== bitmap) scaled.recycle()
1547
+
1548
+ call.resolve(JSObject().apply {
1549
+ put("base64", base64)
1550
+ put("format", outputFormat)
1551
+ put("width", resultWidth)
1552
+ put("height", resultHeight)
1553
+ })
1554
+ }
1555
+ }
1556
+
1557
+ // ---- A2UI Push ----
1558
+
1559
+ @PluginMethod
1560
+ fun a2uiPush(call: PluginCall) {
1561
+ val canvasId = call.getString("canvasId") ?: run {
1562
+ call.reject("Missing canvasId")
1563
+ return
1564
+ }
1565
+ val canvas = canvases[canvasId] ?: run {
1566
+ call.reject("Canvas not found")
1567
+ return
1568
+ }
1569
+ val wv = canvas.webView ?: run {
1570
+ call.reject("No web view - call navigate() first")
1571
+ return
1572
+ }
1573
+
1574
+ // Accept "messages" (JSON array), "jsonl" (newline-delimited JSON string), or "payload".
1575
+ val messagesJSON: String = when {
1576
+ call.getArray("messages") != null -> {
1577
+ call.getArray("messages").toString()
1578
+ }
1579
+ call.getString("jsonl") != null -> {
1580
+ val jsonl = call.getString("jsonl")!!
1581
+ val parsed = JSONArray()
1582
+ for (line in jsonl.split("\n")) {
1583
+ val trimmed = line.trim()
1584
+ if (trimmed.isNotEmpty()) {
1585
+ try {
1586
+ parsed.put(JSONObject(trimmed))
1587
+ } catch (e: Exception) {
1588
+ call.reject("Invalid JSONL at line: ${trimmed.take(80)}")
1589
+ return
1590
+ }
1591
+ }
1592
+ }
1593
+ parsed.toString()
1594
+ }
1595
+ call.getObject("payload") != null -> {
1596
+ val payload = call.getObject("payload")
1597
+ JSONArray().put(payload).toString()
1598
+ }
1599
+ else -> {
1600
+ call.reject("Missing messages, jsonl, or payload parameter")
1601
+ return
1602
+ }
1603
+ }
1604
+
1605
+ val escapedJSON = jsStringLiteral(messagesJSON)
1606
+
1607
+ val js = """
1608
+ (function() {
1609
+ try {
1610
+ var host = globalThis.elizaA2UI;
1611
+ if (host && typeof host.applyMessages === 'function') {
1612
+ host.applyMessages(JSON.parse($escapedJSON));
1613
+ return 'ok';
1614
+ }
1615
+ return 'a2ui_not_ready';
1616
+ } catch (e) {
1617
+ return 'error:' + e.message;
1618
+ }
1619
+ })()
1620
+ """.trimIndent()
1621
+
1622
+ activity.runOnUiThread {
1623
+ wv.evaluateJavascript(js) { result ->
1624
+ val resultStr = result?.replace("\"", "") ?: ""
1625
+ when {
1626
+ resultStr == "a2ui_not_ready" -> {
1627
+ call.reject("A2UI host not ready - ensure the canvas page includes the A2UI runtime")
1628
+ }
1629
+ resultStr.startsWith("error:") -> {
1630
+ call.reject("a2uiPush JS error: $resultStr")
1631
+ }
1632
+ else -> {
1633
+ call.resolve()
1634
+ }
1635
+ }
1636
+ }
1637
+ }
1638
+ }
1639
+
1640
+ // ---- A2UI Reset ----
1641
+
1642
+ @PluginMethod
1643
+ fun a2uiReset(call: PluginCall) {
1644
+ val canvasId = call.getString("canvasId") ?: run {
1645
+ call.reject("Missing canvasId")
1646
+ return
1647
+ }
1648
+ val canvas = canvases[canvasId] ?: run {
1649
+ call.reject("Canvas not found")
1650
+ return
1651
+ }
1652
+ val wv = canvas.webView ?: run {
1653
+ call.reject("No web view - call navigate() first")
1654
+ return
1655
+ }
1656
+
1657
+ val js = """
1658
+ (function() {
1659
+ try {
1660
+ var host = globalThis.elizaA2UI;
1661
+ if (host && typeof host.reset === 'function') {
1662
+ host.reset();
1663
+ return 'ok';
1664
+ }
1665
+ return 'no_reset';
1666
+ } catch (e) {
1667
+ return 'error:' + e.message;
1668
+ }
1669
+ })()
1670
+ """.trimIndent()
1671
+
1672
+ activity.runOnUiThread {
1673
+ wv.evaluateJavascript(js) { result ->
1674
+ if (result != null && result.contains("error:")) {
1675
+ call.reject("a2uiReset failed: $result")
1676
+ } else {
1677
+ call.resolve()
1678
+ }
1679
+ }
1680
+ }
1681
+ }
1682
+
1683
+ // ======== Web View Management ========
1684
+
1685
+ @android.annotation.SuppressLint("SetJavaScriptEnabled")
1686
+ private fun ensureWebView(canvas: ManagedCanvas): WebView {
1687
+ canvas.webView?.let { return it }
1688
+
1689
+ val wv = WebView(context).apply {
1690
+ layoutParams = FrameLayout.LayoutParams(
1691
+ FrameLayout.LayoutParams.MATCH_PARENT,
1692
+ FrameLayout.LayoutParams.MATCH_PARENT
1693
+ )
1694
+ settings.javaScriptEnabled = true
1695
+ settings.domStorageEnabled = true
1696
+ settings.mediaPlaybackRequiresUserGesture = false
1697
+ settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
1698
+ settings.allowFileAccess = true
1699
+ setBackgroundColor(Color.BLACK)
1700
+ isVerticalScrollBarEnabled = true
1701
+ isHorizontalScrollBarEnabled = true
1702
+ }
1703
+
1704
+ // Set up the A2UI message bridge via JS interface.
1705
+ val canvasId = canvas.id
1706
+ val pluginRef = this
1707
+ wv.addJavascriptInterface(object {
1708
+ @JavascriptInterface
1709
+ fun postAction(actionJson: String) {
1710
+ try {
1711
+ val json = JSONObject(actionJson)
1712
+ val userAction = json.optJSONObject("userAction") ?: json
1713
+ val actionName = extractActionName(userAction)
1714
+ val actionId = userAction.optString("id", UUID.randomUUID().toString())
1715
+ val surfaceId = userAction.optString("surfaceId", "main")
1716
+
1717
+ Handler(Looper.getMainLooper()).post {
1718
+ pluginRef.notifyListeners("a2uiAction", JSObject().apply {
1719
+ put("canvasId", canvasId)
1720
+ put("actionId", actionId)
1721
+ put("actionName", actionName ?: "")
1722
+ put("surfaceId", surfaceId)
1723
+ put("userAction", jsObjectFromJSON(userAction))
1724
+ })
1725
+
1726
+ // Dispatch action status acknowledgement.
1727
+ val statusJS = """
1728
+ (function() {
1729
+ var detail = { id: ${jsStringLiteral(actionId)}, ok: true, error: '' };
1730
+ window.dispatchEvent(new CustomEvent('eliza:a2ui-action-status', { detail: detail }));
1731
+ })();
1732
+ """.trimIndent()
1733
+ wv.evaluateJavascript(statusJS, null)
1734
+ }
1735
+ } catch (_: Exception) {
1736
+ }
1737
+ }
1738
+ }, "elizaCanvasA2UIBridge")
1739
+
1740
+ // Navigation delegate: intercept eliza:// deep links, emit events.
1741
+ wv.webViewClient = object : WebViewClient() {
1742
+ override fun shouldOverrideUrlLoading(
1743
+ view: WebView,
1744
+ request: WebResourceRequest
1745
+ ): Boolean {
1746
+ val url = request.url
1747
+ if (url.scheme?.lowercase() == "eliza") {
1748
+ pluginRef.notifyListeners("deepLink", JSObject().apply {
1749
+ put("canvasId", canvasId)
1750
+ put("url", url.toString())
1751
+ })
1752
+ return true
1753
+ }
1754
+ return false
1755
+ }
1756
+
1757
+ override fun onPageFinished(view: WebView, url: String?) {
1758
+ super.onPageFinished(view, url)
1759
+
1760
+ // Inject the A2UI bridge script so web content can post actions.
1761
+ val bridgeScript = """
1762
+ (function() {
1763
+ if (!window.webkit) window.webkit = {};
1764
+ if (!window.webkit.messageHandlers) window.webkit.messageHandlers = {};
1765
+ window.webkit.messageHandlers.elizaCanvasA2UIAction = {
1766
+ postMessage: function(msg) {
1767
+ var json = typeof msg === 'string' ? msg : JSON.stringify(msg);
1768
+ elizaCanvasA2UIBridge.postAction(json);
1769
+ }
1770
+ };
1771
+ })();
1772
+ """.trimIndent()
1773
+ view.evaluateJavascript(bridgeScript, null)
1774
+
1775
+ pluginRef.notifyListeners("webViewReady", JSObject().apply {
1776
+ put("canvasId", canvasId)
1777
+ put("url", url ?: "")
1778
+ })
1779
+ }
1780
+
1781
+ override fun onReceivedError(
1782
+ view: WebView,
1783
+ request: WebResourceRequest,
1784
+ error: WebResourceError
1785
+ ) {
1786
+ super.onReceivedError(view, request, error)
1787
+ pluginRef.notifyListeners("navigationError", JSObject().apply {
1788
+ put("canvasId", canvasId)
1789
+ put("error", error.description?.toString() ?: "Unknown error")
1790
+ put("url", request.url?.toString() ?: "")
1791
+ })
1792
+ }
1793
+ }
1794
+
1795
+ wv.webChromeClient = WebChromeClient()
1796
+
1797
+ canvas.webView = wv
1798
+
1799
+ // Insert the web view behind drawing layers in the canvas view hierarchy.
1800
+ if (canvas.view.parent != null) {
1801
+ wv.layoutParams = FrameLayout.LayoutParams(
1802
+ FrameLayout.LayoutParams.MATCH_PARENT,
1803
+ FrameLayout.LayoutParams.MATCH_PARENT
1804
+ )
1805
+ val parent = canvas.view.parent as? ViewGroup
1806
+ parent?.addView(wv, 0)
1807
+ canvas.view.setBackgroundColor(Color.TRANSPARENT)
1808
+ }
1809
+
1810
+ return wv
1811
+ }
1812
+
1813
+ // ======== Internal Helpers ========
1814
+
1815
+ // ---- Draw Options: shadow, blend mode, opacity, transform ----
1816
+
1817
+ /** Apply draw options and return the Canvas save count for later restore. */
1818
+ private fun applyDrawOptions(
1819
+ canvas: Canvas,
1820
+ managedCanvas: ManagedCanvas,
1821
+ options: JSObject?
1822
+ ): Int {
1823
+ val saveCount = canvas.save()
1824
+
1825
+ // Global canvas transform.
1826
+ if (!managedCanvas.globalTransform.isIdentity) {
1827
+ canvas.concat(managedCanvas.globalTransform)
1828
+ }
1829
+
1830
+ if (options == null) return saveCount
1831
+
1832
+ // Per-operation transform.
1833
+ val transformObj = options.getJSObject("transform")
1834
+ if (transformObj != null) {
1835
+ canvas.concat(matrixFromTransformObject(transformObj))
1836
+ }
1837
+
1838
+ // Blend mode via Paint is per-draw; opacity and shadow are applied via layer.
1839
+ val opacity = options.doubleOrNull("opacity")
1840
+ if (opacity != null && opacity < 1.0) {
1841
+ canvas.saveLayerAlpha(
1842
+ null, (opacity * 255).toInt().coerceIn(0, 255)
1843
+ )
1844
+ }
1845
+
1846
+ return saveCount
1847
+ }
1848
+
1849
+ private fun restoreDrawOptions(canvas: Canvas, saveCount: Int) {
1850
+ canvas.restoreToCount(saveCount)
1851
+ }
1852
+
1853
+ // ---- Shadow ----
1854
+
1855
+ private fun applyShadow(paint: Paint, shadowObj: JSObject?) {
1856
+ if (shadowObj == null) return
1857
+ val blur = shadowObj.float("blur")
1858
+ val offsetX = shadowObj.float("offsetX")
1859
+ val offsetY = shadowObj.float("offsetY")
1860
+ val colorObj = shadowObj.getJSObject("color")
1861
+ val colorStr = shadowObj.stringOrNull("color")
1862
+ val color = when {
1863
+ colorObj != null -> colorFromObject(colorObj)
1864
+ colorStr != null -> colorFromHexString(colorStr)
1865
+ else -> Color.argb(128, 0, 0, 0)
1866
+ }
1867
+ paint.setShadowLayer(blur, offsetX, offsetY, color)
1868
+ }
1869
+
1870
+ // ---- Blend Mode ----
1871
+
1872
+ private fun blendModeFromString(mode: String): PorterDuff.Mode {
1873
+ return when (mode) {
1874
+ "multiply" -> PorterDuff.Mode.MULTIPLY
1875
+ "screen" -> PorterDuff.Mode.SCREEN
1876
+ "overlay" -> PorterDuff.Mode.OVERLAY
1877
+ "darken" -> PorterDuff.Mode.DARKEN
1878
+ "lighten" -> PorterDuff.Mode.LIGHTEN
1879
+ else -> PorterDuff.Mode.SRC_OVER
1880
+ }
1881
+ }
1882
+
1883
+ // ---- Stroke Style ----
1884
+
1885
+ private fun applyStrokeStyle(paint: Paint, strokeObj: JSObject) {
1886
+ val lineCap = strokeObj.getString("lineCap")
1887
+ paint.strokeCap = when (lineCap) {
1888
+ "round" -> Paint.Cap.ROUND
1889
+ "square" -> Paint.Cap.SQUARE
1890
+ else -> Paint.Cap.BUTT
1891
+ }
1892
+
1893
+ val lineJoin = strokeObj.getString("lineJoin")
1894
+ paint.strokeJoin = when (lineJoin) {
1895
+ "round" -> Paint.Join.ROUND
1896
+ "bevel" -> Paint.Join.BEVEL
1897
+ else -> Paint.Join.MITER
1898
+ }
1899
+
1900
+ val dashPattern = strokeObj.arrayOrNull("dashPattern")
1901
+ if (dashPattern != null && dashPattern.length() > 0) {
1902
+ val intervals = FloatArray(dashPattern.length()) {
1903
+ dashPattern.optDouble(it, 0.0).toFloat()
1904
+ }
1905
+ if (intervals.size >= 2) {
1906
+ paint.pathEffect = DashPathEffect(intervals, 0f)
1907
+ }
1908
+ }
1909
+ }
1910
+
1911
+ // ---- Gradient ----
1912
+
1913
+ private fun extractGradient(obj: JSObject): JSObject? {
1914
+ val type = obj.getString("type") ?: return null
1915
+ if (type == "linear" || type == "radial") return obj
1916
+ return null
1917
+ }
1918
+
1919
+ private fun createShader(gradientObj: JSObject): Shader {
1920
+ val type = gradientObj.getString("type") ?: "linear"
1921
+ val stops = gradientObj.arrayOrNull("stops")
1922
+
1923
+ val colors = mutableListOf<Int>()
1924
+ val positions = mutableListOf<Float>()
1925
+
1926
+ if (stops != null) {
1927
+ for (i in 0 until stops.length()) {
1928
+ val stop = stops.getJSONObject(i)
1929
+ positions.add(stop.optDouble("offset", 0.0).toFloat())
1930
+
1931
+ val colorObj = stop.optJSONObject("color")
1932
+ val colorStr = stop.stringOrNull("color")
1933
+ val color = when {
1934
+ colorObj != null -> colorFromObject(jsObjectFromJSON(colorObj))
1935
+ colorStr != null && colorStr.startsWith("#") -> colorFromHexString(colorStr)
1936
+ else -> Color.BLACK
1937
+ }
1938
+ colors.add(color)
1939
+ }
1940
+ }
1941
+
1942
+ if (colors.isEmpty()) {
1943
+ colors.add(Color.BLACK)
1944
+ colors.add(Color.BLACK)
1945
+ positions.add(0f)
1946
+ positions.add(1f)
1947
+ }
1948
+
1949
+ return when (type) {
1950
+ "radial" -> {
1951
+ val x0 = gradientObj.float("x0")
1952
+ val y0 = gradientObj.float("y0")
1953
+ val r1 = gradientObj.float("r1", 1f)
1954
+ RadialGradient(
1955
+ x0, y0, r1.coerceAtLeast(0.001f),
1956
+ colors.toIntArray(), positions.toFloatArray(),
1957
+ Shader.TileMode.CLAMP
1958
+ )
1959
+ }
1960
+ else -> {
1961
+ val x0 = gradientObj.float("x0")
1962
+ val y0 = gradientObj.float("y0")
1963
+ val x1 = gradientObj.float("x1")
1964
+ val y1 = gradientObj.float("y1")
1965
+ LinearGradient(
1966
+ x0, y0, x1, y1,
1967
+ colors.toIntArray(), positions.toFloatArray(),
1968
+ Shader.TileMode.CLAMP
1969
+ )
1970
+ }
1971
+ }
1972
+ }
1973
+
1974
+ // ---- Transform ----
1975
+
1976
+ private fun matrixFromTransformObject(obj: JSObject): Matrix {
1977
+ val m = Matrix()
1978
+ val tx = obj.float("translateX")
1979
+ val ty = obj.float("translateY")
1980
+ if (tx != 0f || ty != 0f) m.postTranslate(tx, ty)
1981
+
1982
+ val sx = obj.floatOrNull("scaleX")
1983
+ val sy = obj.floatOrNull("scaleY")
1984
+ if (sx != null || sy != null) {
1985
+ m.postScale(sx ?: 1f, sy ?: 1f)
1986
+ }
1987
+
1988
+ val rotation = obj.floatOrNull("rotation")
1989
+ if (rotation != null && rotation != 0f) {
1990
+ m.postRotate(Math.toDegrees(rotation.toDouble()).toFloat())
1991
+ }
1992
+
1993
+ val skewX = obj.floatOrNull("skewX")
1994
+ if (skewX != null && skewX != 0f) {
1995
+ val skewMatrix = Matrix()
1996
+ skewMatrix.setSkew(tan(skewX.toDouble()).toFloat(), 0f)
1997
+ m.postConcat(skewMatrix)
1998
+ }
1999
+
2000
+ val skewY = obj.floatOrNull("skewY")
2001
+ if (skewY != null && skewY != 0f) {
2002
+ val skewMatrix = Matrix()
2003
+ skewMatrix.setSkew(0f, tan(skewY.toDouble()).toFloat())
2004
+ m.postConcat(skewMatrix)
2005
+ }
2006
+
2007
+ return m
2008
+ }
2009
+
2010
+ // ---- Color Utilities ----
2011
+
2012
+ private fun colorFromObject(obj: JSObject?): Int {
2013
+ if (obj == null) return Color.BLACK
2014
+ val r = obj.int("r")
2015
+ val g = obj.int("g")
2016
+ val b = obj.int("b")
2017
+ val a = (obj.double("a", 1.0) * 255).toInt()
2018
+ return Color.argb(a, r, g, b)
2019
+ }
2020
+
2021
+ /**
2022
+ * Extract a color from a fill or stroke style object. Handles both `{ color: ... }` wrappers
2023
+ * and direct color objects, as well as hex string colors.
2024
+ */
2025
+ private fun colorFromFillOrStroke(obj: JSObject): Int {
2026
+ val colorObj = obj.getJSObject("color")
2027
+ if (colorObj != null) return colorFromObject(colorObj)
2028
+
2029
+ val colorStr = obj.getString("color")
2030
+ if (colorStr != null) return colorFromHexString(colorStr)
2031
+
2032
+ // Maybe the object itself is a color.
2033
+ if (obj.has("r")) return colorFromObject(obj)
2034
+
2035
+ return Color.BLACK
2036
+ }
2037
+
2038
+ private fun colorFromHexString(hex: String): Int {
2039
+ var sanitized = hex.trim().removePrefix("#")
2040
+ return try {
2041
+ when (sanitized.length) {
2042
+ 6 -> {
2043
+ val rgb = sanitized.toLong(16).toInt()
2044
+ Color.rgb(
2045
+ (rgb shr 16) and 0xFF,
2046
+ (rgb shr 8) and 0xFF,
2047
+ rgb and 0xFF
2048
+ )
2049
+ }
2050
+ 8 -> {
2051
+ val rgba = sanitized.toLong(16)
2052
+ Color.argb(
2053
+ (rgba and 0xFF).toInt(),
2054
+ ((rgba shr 24) and 0xFF).toInt(),
2055
+ ((rgba shr 16) and 0xFF).toInt(),
2056
+ ((rgba shr 8) and 0xFF).toInt()
2057
+ )
2058
+ }
2059
+ else -> Color.BLACK
2060
+ }
2061
+ } catch (_: Exception) {
2062
+ Color.BLACK
2063
+ }
2064
+ }
2065
+
2066
+ // ---- Rect Utilities ----
2067
+
2068
+ private fun rectFromObject(obj: JSObject): RectF {
2069
+ val x = obj.float("x")
2070
+ val y = obj.float("y")
2071
+ val w = obj.float("width")
2072
+ val h = obj.float("height")
2073
+ return RectF(x, y, x + w, y + h)
2074
+ }
2075
+
2076
+ private fun JSObject.boolean(name: String, defaultValue: Boolean = false): Boolean =
2077
+ booleanOrNull(name) ?: defaultValue
2078
+
2079
+ private fun JSObject.booleanOrNull(name: String): Boolean? =
2080
+ if (has(name) && !isNull(name)) optBoolean(name) else null
2081
+
2082
+ private fun JSObject.double(name: String, defaultValue: Double = 0.0): Double =
2083
+ doubleOrNull(name) ?: defaultValue
2084
+
2085
+ private fun JSObject.doubleOrNull(name: String): Double? =
2086
+ if (has(name) && !isNull(name)) optDouble(name) else null
2087
+
2088
+ private fun JSObject.float(name: String, defaultValue: Float = 0f): Float =
2089
+ doubleOrNull(name)?.toFloat() ?: defaultValue
2090
+
2091
+ private fun JSObject.floatOrNull(name: String): Float? = doubleOrNull(name)?.toFloat()
2092
+
2093
+ private fun JSObject.int(name: String, defaultValue: Int = 0): Int =
2094
+ intOrNull(name) ?: defaultValue
2095
+
2096
+ private fun JSObject.intOrNull(name: String): Int? =
2097
+ if (has(name) && !isNull(name)) optInt(name) else null
2098
+
2099
+ private fun JSObject.arrayOrNull(name: String): JSONArray? =
2100
+ if (has(name) && !isNull(name)) optJSONArray(name) else null
2101
+
2102
+ private fun JSObject.stringOrNull(name: String): String? =
2103
+ if (has(name) && !isNull(name)) opt(name)?.takeUnless { it === JSONObject.NULL }?.toString() else null
2104
+
2105
+ private fun JSONObject.stringOrNull(name: String): String? =
2106
+ if (has(name) && !isNull(name)) opt(name)?.takeUnless { it === JSONObject.NULL }?.toString() else null
2107
+
2108
+ private fun rectFromJSON(obj: JSONObject): RectF {
2109
+ val x = obj.optDouble("x", 0.0).toFloat()
2110
+ val y = obj.optDouble("y", 0.0).toFloat()
2111
+ val w = obj.optDouble("width", 0.0).toFloat()
2112
+ val h = obj.optDouble("height", 0.0).toFloat()
2113
+ return RectF(x, y, x + w, y + h)
2114
+ }
2115
+
2116
+ // ---- JSON Conversion ----
2117
+
2118
+ private fun jsObjectFromJSON(json: JSONObject): JSObject {
2119
+ val result = JSObject()
2120
+ val keys = json.keys()
2121
+ while (keys.hasNext()) {
2122
+ val key = keys.next()
2123
+ result.put(key, json.get(key))
2124
+ }
2125
+ return result
2126
+ }
2127
+
2128
+ // ---- A2UI Action Name ----
2129
+
2130
+ private fun extractActionName(userAction: JSONObject): String? {
2131
+ for (key in listOf("name", "action")) {
2132
+ val raw = userAction.optString(key, "").trim()
2133
+ if (raw.isNotEmpty()) return raw
2134
+ }
2135
+ return null
2136
+ }
2137
+
2138
+ // ---- JS String Escape (matches iOS jsStringLiteral) ----
2139
+
2140
+ private fun jsStringLiteral(value: String): String {
2141
+ val escaped = value
2142
+ .replace("\\", "\\\\")
2143
+ .replace("\"", "\\\"")
2144
+ .replace("\n", "\\n")
2145
+ .replace("\r", "\\r")
2146
+ .replace("\t", "\\t")
2147
+ return "\"$escaped\""
2148
+ }
2149
+
2150
+ // ---- Layer Sorting ----
2151
+
2152
+ private fun sortLayers(canvas: ManagedCanvas) {
2153
+ val parent = canvas.view.parent as? ViewGroup ?: return
2154
+ val sorted = canvas.layers.values.sortedBy { it.zIndex }
2155
+ sorted.forEachIndexed { index, layer ->
2156
+ parent.removeView(layer.view)
2157
+ // Offset by 1 if web view is at index 0 inside canvas.view.
2158
+ parent.addView(layer.view, index + 1)
2159
+ }
2160
+ }
2161
+
2162
+ // ---- Lifecycle ----
2163
+
2164
+ override fun handleOnDestroy() {
2165
+ super.handleOnDestroy()
2166
+ canvases.values.forEach { canvas ->
2167
+ canvas.webView?.destroy()
2168
+ (canvas.view.parent as? ViewGroup)?.removeView(canvas.view)
2169
+ canvas.layers.values.forEach { layer ->
2170
+ (layer.view.parent as? ViewGroup)?.removeView(layer.view)
2171
+ }
2172
+ }
2173
+ canvases.clear()
2174
+ }
2175
+ }