@react-native-harness/ui 1.6.0 → 1.7.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.
Files changed (46) hide show
  1. package/Package.swift +40 -0
  2. package/README.md +7 -0
  3. package/android/src/main/java/com/harnessui/HarnessUIModule.kt +2 -1
  4. package/android/src/main/java/com/harnessui/UIHelperImpl.kt +409 -74
  5. package/dist/NativeHarnessUI.d.ts +3 -1
  6. package/dist/NativeHarnessUI.d.ts.map +1 -1
  7. package/dist/WebHarnessUI.js +2 -2
  8. package/dist/screen.d.ts.map +1 -1
  9. package/dist/screen.js +1 -1
  10. package/dist/types.d.ts +6 -4
  11. package/dist/types.d.ts.map +1 -1
  12. package/ios/HarnessUI.h +2 -0
  13. package/ios/HarnessUI.mm +6 -4
  14. package/ios/ViewQueryHelper.mm +16 -14
  15. package/package.json +6 -2
  16. package/src/NativeHarnessUI.ts +1 -1
  17. package/src/WebHarnessUI.ts +2 -2
  18. package/src/screen.ts +3 -1
  19. package/src/types.ts +4 -4
  20. package/dist/HarnessUI.d.ts +0 -17
  21. package/dist/HarnessUI.d.ts.map +0 -1
  22. package/dist/HarnessUI.js +0 -2
  23. package/dist/HarnessUI.web.d.ts +0 -14
  24. package/dist/HarnessUI.web.d.ts.map +0 -1
  25. package/dist/HarnessUI.web.js +0 -46
  26. package/dist/NativeHarness.d.ts +0 -22
  27. package/dist/NativeHarness.d.ts.map +0 -1
  28. package/dist/NativeHarness.js +0 -2
  29. package/dist/WebHarness.d.ts +0 -14
  30. package/dist/WebHarness.d.ts.map +0 -1
  31. package/dist/WebHarness.js +0 -53
  32. package/dist/harness/NativeHarness.d.ts +0 -17
  33. package/dist/harness/NativeHarness.d.ts.map +0 -1
  34. package/dist/harness/NativeHarness.js +0 -2
  35. package/dist/harness/WebHarness.d.ts +0 -14
  36. package/dist/harness/WebHarness.d.ts.map +0 -1
  37. package/dist/harness/WebHarness.js +0 -53
  38. package/dist/harness/index.d.ts +0 -4
  39. package/dist/harness/index.d.ts.map +0 -1
  40. package/dist/harness/index.js +0 -8
  41. package/dist/harness/native-harness.d.ts +0 -17
  42. package/dist/harness/native-harness.d.ts.map +0 -1
  43. package/dist/harness/native-harness.js +0 -2
  44. package/dist/harness/web-harness.d.ts +0 -14
  45. package/dist/harness/web-harness.d.ts.map +0 -1
  46. package/dist/harness/web-harness.js +0 -53
package/Package.swift ADDED
@@ -0,0 +1,40 @@
1
+ // swift-tools-version: 6.0
2
+
3
+ import PackageDescription
4
+
5
+ // React Native resolves these dependencies from
6
+ // build/generated/autolinking/libs/HarnessUI in the app's iOS directory.
7
+ let package = Package(
8
+ name: "HarnessUI",
9
+ platforms: [.iOS("15.1")],
10
+ products: [
11
+ .library(name: "HarnessUI", targets: ["HarnessUI"]),
12
+ ],
13
+ dependencies: [
14
+ .package(name: "ReactNative", path: "../../../../xcframeworks"),
15
+ .package(name: "React-GeneratedCode", path: "../../../ios"),
16
+ ],
17
+ targets: [
18
+ .target(
19
+ name: "HarnessUI",
20
+ dependencies: [
21
+ .product(name: "ReactHeaders", package: "ReactNative"),
22
+ .product(name: "ReactNativeHeaders", package: "ReactNative"),
23
+ .product(name: "ReactNativeDependenciesHeaders", package: "ReactNative"),
24
+ .product(name: "ReactAppHeaders", package: "React-GeneratedCode"),
25
+ ],
26
+ path: "ios",
27
+ publicHeadersPath: ".",
28
+ cxxSettings: [
29
+ .define("DEBUG", .when(configuration: .debug)),
30
+ .define("NDEBUG", .when(configuration: .release)),
31
+ ],
32
+ linkerSettings: [
33
+ .linkedFramework("UIKit"),
34
+ .linkedFramework("Foundation"),
35
+ .linkedFramework("CoreGraphics"),
36
+ ]
37
+ ),
38
+ ],
39
+ cxxLanguageStandard: .cxx20
40
+ )
package/README.md CHANGED
@@ -26,6 +26,13 @@ pnpm add @react-native-harness/ui
26
26
  yarn add @react-native-harness/ui
27
27
  ```
28
28
 
29
+ ### iOS with Swift Package Manager
30
+
31
+ For apps using React Native's SwiftPM integration, run `npx react-native spm` from
32
+ the app directory after installing this package. The package includes the
33
+ `HarnessUI` SwiftPM manifest and autolinking metadata; no local manifest patch is
34
+ needed. Apps using CocoaPods can continue to use `pod install`.
35
+
29
36
  ## Usage
30
37
 
31
38
  Import the UI testing utilities in your test files:
@@ -41,9 +41,10 @@ class HarnessUIModule(
41
41
  override fun queryAllByAccessibilityLabel(label: String): WritableArray = helper.queryAllByAccessibilityLabel(label)
42
42
 
43
43
  override fun captureScreenshot(
44
- bounds: ReadableMap?,
44
+ options: ReadableMap,
45
45
  promise: Promise,
46
46
  ) {
47
+ val bounds = if (options.hasKey("bounds")) options.getMap("bounds") else null
47
48
  helper.captureScreenshot(bounds, promise)
48
49
  }
49
50
 
@@ -3,12 +3,23 @@ package com.harnessui
3
3
  import android.content.Context
4
4
  import android.graphics.Bitmap
5
5
  import android.graphics.Canvas
6
+ import android.graphics.Matrix
7
+ import android.graphics.Paint
8
+ import android.graphics.PorterDuff
9
+ import android.graphics.PorterDuffXfermode
6
10
  import android.graphics.Rect
11
+ import android.graphics.RectF
12
+ import android.os.Build
7
13
  import android.os.Handler
8
14
  import android.os.Looper
9
15
  import android.os.SystemClock
10
16
  import android.util.Log
11
17
  import android.view.MotionEvent
18
+ import android.view.PixelCopy
19
+ import android.view.SurfaceView
20
+ import android.view.View
21
+ import android.view.ViewGroup
22
+ import android.view.Window
12
23
  import android.view.inputmethod.EditorInfo
13
24
  import android.view.inputmethod.InputMethodManager
14
25
  import android.widget.EditText
@@ -24,6 +35,9 @@ import java.io.ByteArrayOutputStream
24
35
  import java.nio.ByteBuffer
25
36
  import java.util.concurrent.CountDownLatch
26
37
  import java.util.concurrent.TimeUnit
38
+ import kotlin.math.floor
39
+ import kotlin.math.min
40
+ import kotlin.math.roundToInt
27
41
 
28
42
  /**
29
43
  * UI helper implementation for HarnessUI.
@@ -36,6 +50,8 @@ class UIHelperImpl(
36
50
  private const val TAG = "HarnessUI"
37
51
  private const val TAP_DURATION_MS = 50L // Duration between touch down and up
38
52
  private const val EVENT_PROCESSING_DELAY_MS = 10L // Delay after touch up for React Native to process the event
53
+ private const val GEOMETRY_FUDGE_FACTOR = 0.0656f // How far the renderer widens a box it snaps to pixels
54
+ private const val COPY_ATTEMPTS = 4 // Requests of a copy the renderer abandons at its 500 ms buffer fence wait
39
55
  }
40
56
 
41
57
  private val mainHandler = Handler(Looper.getMainLooper())
@@ -250,7 +266,8 @@ class UIHelperImpl(
250
266
  return@runOnUiThread
251
267
  }
252
268
 
253
- val root = activity.window.decorView.rootView
269
+ val window = activity.window
270
+ val root = window.decorView.rootView
254
271
  val density = root.resources.displayMetrics.density
255
272
 
256
273
  try {
@@ -258,99 +275,417 @@ class UIHelperImpl(
258
275
  val nativeId = if (bounds != null && bounds.hasKey("nativeId")) bounds.getString("nativeId") else null
259
276
 
260
277
  if (!nativeId.isNullOrEmpty()) {
278
+ // Element screenshots never fall back to a window/region capture.
261
279
  val targetView = ViewRegistry.get(nativeId)
262
- if (targetView != null) {
263
- val width = targetView.width
264
- val height = targetView.height
265
-
266
- if (width > 0 && height > 0) {
267
- val location = IntArray(2)
268
- targetView.getLocationOnScreen(location)
269
- val viewRect = Rect(location[0], location[1], location[0] + width, location[1] + height)
270
- val rootRect = Rect(0, 0, root.width, root.height)
271
-
272
- val isFullyOnScreen = rootRect.contains(viewRect)
273
- val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
274
- val canvas = Canvas(bitmap)
275
-
276
- if (isFullyOnScreen) {
277
- Log.i(TAG, "View $nativeId is fully on screen. Using screen-based screenshotting.")
278
- // Translate canvas to capture the view from the root view hierarchy
279
- canvas.translate(-location[0].toFloat(), -location[1].toFloat())
280
- root.draw(canvas)
281
- } else {
282
- Log.i(TAG, "View $nativeId is partially/fully off screen. Using direct view render.")
283
- // Draw the specific view directly
284
- targetView.draw(canvas)
285
- }
280
+ if (targetView == null) {
281
+ Log.w(TAG, "View with nativeId $nativeId not found for screenshot")
282
+ promise.resolve(null)
283
+ return@runOnUiThread
284
+ }
286
285
 
287
- val outputStream = ByteArrayOutputStream()
288
- bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
289
- val pngBytes = outputStream.toByteArray()
290
- bitmap.recycle()
286
+ val width = targetView.width
287
+ val height = targetView.height
291
288
 
292
- val base64String = android.util.Base64.encodeToString(pngBytes, android.util.Base64.NO_WRAP)
293
- promise.resolve(base64String)
294
- return@runOnUiThread
295
- }
289
+ if (width <= 0 || height <= 0) {
290
+ Log.w(TAG, "View $nativeId has zero size ($width x $height), nothing to capture")
291
+ promise.resolve(null)
292
+ return@runOnUiThread
293
+ }
294
+
295
+ val viewRect = rectInWindow(targetView)
296
+ val rootRect = Rect(0, 0, root.width, root.height)
297
+
298
+ if (targetView.isAttachedToWindow && !viewRect.isEmpty && rootRect.contains(viewRect)) {
299
+ Log.i(TAG, "View $nativeId is fully on screen. Using screen-based screenshotting.")
300
+ captureWindowRegion(window, viewRect, promise)
296
301
  } else {
297
- Log.w(TAG, "View with nativeId $nativeId not found for screenshot, falling back to window screenshot")
302
+ Log.i(TAG, "View $nativeId is not fully on screen. Using direct view render.")
303
+ promise.resolve(encodePng(drawView(targetView)))
298
304
  }
305
+ return@runOnUiThread
299
306
  }
300
307
 
301
- // Determine capture dimensions
302
- val captureX: Int
303
- val captureY: Int
304
- val captureWidth: Int
305
- val captureHeight: Int
306
-
307
308
  // Check if bounds are provided and have non-zero dimensions
308
- val hasBounds = bounds != null && bounds.getDouble("width") > 0 && bounds.getDouble("height") > 0
309
-
310
- if (hasBounds) {
311
- // Convert dp to px for bounds
312
- captureX = (bounds!!.getDouble("x") * density).toInt()
313
- captureY = (bounds.getDouble("y") * density).toInt()
314
- captureWidth = (bounds.getDouble("width") * density).toInt()
315
- captureHeight = (bounds.getDouble("height") * density).toInt()
316
- Log.i(TAG, "Capturing region: x=$captureX y=$captureY w=$captureWidth h=$captureHeight")
317
- } else {
318
- captureX = 0
319
- captureY = 0
320
- captureWidth = root.width
321
- captureHeight = root.height
322
- Log.i(TAG, "Capturing full window: w=$captureWidth h=$captureHeight")
323
- }
309
+ val region =
310
+ if (bounds != null && bounds.getDouble("width") > 0 && bounds.getDouble("height") > 0) {
311
+ // Convert dp to px, rounding each edge (not the size) so the region covers the same
312
+ // pixels React Native lays out for a view with these bounds.
313
+ val boundsX = bounds.getDouble("x")
314
+ val boundsY = bounds.getDouble("y")
315
+ val left = (boundsX * density).roundToInt()
316
+ val top = (boundsY * density).roundToInt()
317
+ val right = ((boundsX + bounds.getDouble("width")) * density).roundToInt()
318
+ val bottom = ((boundsY + bounds.getDouble("height")) * density).roundToInt()
319
+ Log.i(TAG, "Capturing region: x=$left y=$top w=${right - left} h=${bottom - top}")
320
+ Rect(left, top, right, bottom)
321
+ } else {
322
+ Log.i(TAG, "Capturing full window: w=${root.width} h=${root.height}")
323
+ Rect(0, 0, root.width, root.height)
324
+ }
325
+
326
+ captureWindowRegion(window, region, promise)
327
+ } catch (e: Exception) {
328
+ Log.e(TAG, "Screenshot capture failed", e)
329
+ promise.resolve(null)
330
+ }
331
+ }
332
+ }
333
+
334
+ // A software draw of the view tree leaves out pixels a view keeps on a surface of its own: a TextureView draws
335
+ // nothing into a software canvas, and a SurfaceView's content lives on a separate surface behind the window, which
336
+ // leaves a transparent hole over it. PixelCopy reads the window's surface, which the TextureViews render into, and
337
+ // each SurfaceView's surface, each at the latest frame it holds when its copy runs. A SurfaceView set above its
338
+ // window with setZOrderOnTop punches no hole, so it stays hidden: before Android 16 only hidden API tells one
339
+ // apart, and the SDK this module compiles against by default predates getCompositionOrder().
340
+ private fun captureWindowRegion(
341
+ window: Window,
342
+ region: Rect,
343
+ promise: Promise,
344
+ ) {
345
+ val decorView = window.decorView
346
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
347
+ promise.resolve(encodePng(drawWindowRegion(decorView, region)))
348
+ return
349
+ }
350
+ // A window that is not shown, as while the screen is off, has nothing on the screen to copy.
351
+ if (!decorView.isShown) {
352
+ Log.e(TAG, "Screenshot capture failed: the window is not shown")
353
+ promise.resolve(null)
354
+ return
355
+ }
324
356
 
325
- // Create bitmap and canvas
326
- val bitmap = Bitmap.createBitmap(captureWidth, captureHeight, Bitmap.Config.ARGB_8888)
327
- val canvas = Canvas(bitmap)
357
+ // PixelCopy stretches a source rect that runs past the window over the whole bitmap, so only the part on the
358
+ // window is copied, and placed at its offset in the region.
359
+ val copiedRegion = Rect(0, 0, decorView.width, decorView.height)
360
+ if (!copiedRegion.intersect(region)) {
361
+ promise.resolve(encodePng(drawWindowRegion(decorView, region)))
362
+ return
363
+ }
328
364
 
329
- // Translate canvas if capturing a specific region
330
- canvas.translate(-captureX.toFloat(), -captureY.toFloat())
365
+ val surfaceViews =
366
+ collectSurfaceViews(decorView).filter { surfaceView ->
367
+ !surfaceView.rect.isEmpty && Rect.intersects(copiedRegion, surfaceView.rect)
368
+ }
369
+ val surfaceLayers = arrayOfNulls<SurfaceLayer>(surfaceViews.size)
370
+ var windowSource: WindowSource = WindowSource.ViewTree
371
+ var copyFailure: String? = null
372
+ var pendingCopies = surfaceViews.size + 1
331
373
 
332
- // Draw the view hierarchy
333
- root.draw(canvas)
374
+ fun failCopy(reason: String) {
375
+ copyFailure = copyFailure ?: reason
376
+ }
334
377
 
335
- // Convert to PNG bytes
336
- val outputStream = ByteArrayOutputStream()
337
- bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
338
- val pngBytes = outputStream.toByteArray()
378
+ fun onCopyFinished() {
379
+ pendingCopies -= 1
380
+ if (pendingCopies > 0) {
381
+ return
382
+ }
383
+ val layers = surfaceLayers.filterNotNull()
339
384
 
340
- // Clean up
341
- bitmap.recycle()
385
+ fun abandon() {
386
+ when (val source = windowSource) {
387
+ is WindowSource.Copy -> source.pixels.recycle()
388
+ WindowSource.ViewTree -> Unit
389
+ }
390
+ layers.forEach { layer -> layer.pixels.recycle() }
391
+ promise.resolve(null)
392
+ }
342
393
 
343
- // Return as Base64 string for efficiency
344
- val base64String = android.util.Base64.encodeToString(pngBytes, android.util.Base64.NO_WRAP)
345
- Log.i(TAG, "Screenshot captured successfully (${pngBytes.size} bytes)")
346
- promise.resolve(base64String)
394
+ val failure = copyFailure
395
+ if (failure != null) {
396
+ Log.e(TAG, "Screenshot capture failed: $failure")
397
+ abandon()
398
+ return
399
+ }
400
+ var screenshot: Bitmap? = null
401
+ try {
402
+ screenshot =
403
+ when (val source = windowSource) {
404
+ is WindowSource.Copy -> placeInRegion(source.pixels, copiedRegion, region)
405
+ WindowSource.ViewTree -> drawWindowRegion(decorView, region)
406
+ }
407
+ drawBehind(screenshot, layers, Rect(copiedRegion).apply { offset(-region.left, -region.top) })
408
+ promise.resolve(encodePng(screenshot))
347
409
  } catch (e: Exception) {
348
410
  Log.e(TAG, "Screenshot capture failed", e)
349
- promise.resolve(null)
411
+ screenshot?.recycle()
412
+ abandon()
413
+ }
414
+ }
415
+
416
+ // Before Android 9, PixelCopy does not offset the rect it is given by the window's surface insets, which a
417
+ // window with elevation has, so such a window's view tree is drawn instead.
418
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P && decorView.z > 0f) {
419
+ onCopyFinished()
420
+ } else {
421
+ val windowPixels = Bitmap.createBitmap(copiedRegion.width(), copiedRegion.height(), Bitmap.Config.ARGB_8888)
422
+ val copyWindow = { listener: PixelCopy.OnPixelCopyFinishedListener ->
423
+ PixelCopy.request(window, Rect(copiedRegion), windowPixels, listener, mainHandler)
424
+ }
425
+ requestCopy(copyWindow) { outcome ->
426
+ when (outcome) {
427
+ is CopyOutcome.Finished ->
428
+ if (outcome.result == PixelCopy.SUCCESS) {
429
+ windowSource = WindowSource.Copy(windowPixels)
430
+ } else {
431
+ windowPixels.recycle()
432
+ failCopy("PixelCopy of the window failed with result ${outcome.result}")
433
+ }
434
+ is CopyOutcome.SourceGone -> {
435
+ windowPixels.recycle()
436
+ failCopy("PixelCopy cannot read the window, which has no surface: ${outcome.error.message}")
437
+ }
438
+ }
439
+ onCopyFinished()
440
+ }
441
+ }
442
+
443
+ // A SurfaceView's buffer can differ in size from its view, as a video decoder's does, and the screen shows it
444
+ // scaled to the view's box, so the whole buffer is copied, at no more than the view's own size, and drawn into
445
+ // the box.
446
+ surfaceViews.forEachIndexed { index, surfaceView ->
447
+ val pixels =
448
+ Bitmap.createBitmap(
449
+ min(surfaceView.rect.width(), surfaceView.view.width),
450
+ min(surfaceView.rect.height(), surfaceView.view.height),
451
+ Bitmap.Config.ARGB_8888,
452
+ )
453
+ val box = Rect(surfaceView.rect).apply { offset(-region.left, -region.top) }
454
+ val copySurface = { listener: PixelCopy.OnPixelCopyFinishedListener ->
455
+ PixelCopy.request(surfaceView.view, pixels, listener, mainHandler)
456
+ }
457
+ requestCopy(copySurface) { outcome ->
458
+ when (outcome) {
459
+ is CopyOutcome.Finished ->
460
+ when (val result = outcome.result) {
461
+ PixelCopy.SUCCESS -> surfaceLayers[index] = SurfaceLayer(pixels, box)
462
+ // A surface with no frame yet, one that is gone and a protected one hold nothing a copy can
463
+ // read, so they add no layer.
464
+ PixelCopy.ERROR_SOURCE_NO_DATA, PixelCopy.ERROR_SOURCE_INVALID -> pixels.recycle()
465
+ else -> {
466
+ failCopy("PixelCopy of a SurfaceView failed with result $result")
467
+ pixels.recycle()
468
+ }
469
+ }
470
+ is CopyOutcome.SourceGone -> {
471
+ Log.w(TAG, "PixelCopy cannot read a SurfaceView, whose surface is gone", outcome.error)
472
+ pixels.recycle()
473
+ }
474
+ }
475
+ onCopyFinished()
350
476
  }
351
477
  }
352
478
  }
353
479
 
480
+ private sealed interface WindowSource {
481
+ class Copy(val pixels: Bitmap) : WindowSource
482
+
483
+ object ViewTree : WindowSource
484
+ }
485
+
486
+ private sealed interface CopyOutcome {
487
+ class Finished(val result: Int) : CopyOutcome
488
+
489
+ class SourceGone(val error: IllegalArgumentException) : CopyOutcome
490
+ }
491
+
492
+ // The renderer abandons a copy whose source buffer is still being drawn when its fence wait ends (ERROR_TIMEOUT),
493
+ // so a slow GPU gets the copy requested again, up to COPY_ATTEMPTS times, before it counts as failed.
494
+ private fun requestCopy(
495
+ request: (PixelCopy.OnPixelCopyFinishedListener) -> Unit,
496
+ attempt: Int = 1,
497
+ onOutcome: (CopyOutcome) -> Unit,
498
+ ) {
499
+ try {
500
+ request(
501
+ PixelCopy.OnPixelCopyFinishedListener { result ->
502
+ if (result == PixelCopy.ERROR_TIMEOUT && attempt < COPY_ATTEMPTS) {
503
+ requestCopy(request, attempt + 1, onOutcome)
504
+ } else {
505
+ onOutcome(CopyOutcome.Finished(result))
506
+ }
507
+ },
508
+ )
509
+ } catch (e: IllegalArgumentException) {
510
+ onOutcome(CopyOutcome.SourceGone(e))
511
+ }
512
+ }
513
+
514
+ private fun drawView(view: View): Bitmap =
515
+ drawRegion(Rect(0, 0, view.width, view.height)) { canvas -> drawScrolled(view, canvas) }
516
+
517
+ // The window's view tree drawn in software where the window shows it, through the root view's transform and the
518
+ // window's pan and cut at the window's edges, so the region and the SurfaceViews' boxes line up with it.
519
+ private fun drawWindowRegion(
520
+ rootView: View,
521
+ region: Rect,
522
+ ): Bitmap =
523
+ drawRegion(region) { canvas ->
524
+ canvas.clipRect(0, 0, rootView.width, rootView.height)
525
+ canvas.concat(viewToWindow(rootView))
526
+ drawScrolled(rootView, canvas)
527
+ }
528
+
529
+ // A view draws its content in its scrolled space, which the parent's draw otherwise translates it into.
530
+ private fun drawScrolled(
531
+ view: View,
532
+ canvas: Canvas,
533
+ ) {
534
+ canvas.translate(-view.scrollX.toFloat(), -view.scrollY.toFloat())
535
+ view.draw(canvas)
536
+ }
537
+
538
+ private fun drawRegion(
539
+ region: Rect,
540
+ draw: (Canvas) -> Unit,
541
+ ): Bitmap {
542
+ val bitmap = Bitmap.createBitmap(region.width(), region.height(), Bitmap.Config.ARGB_8888)
543
+ try {
544
+ val canvas = Canvas(bitmap)
545
+ canvas.translate(-region.left.toFloat(), -region.top.toFloat())
546
+ draw(canvas)
547
+ } catch (e: Exception) {
548
+ bitmap.recycle()
549
+ throw e
550
+ }
551
+ return bitmap
552
+ }
553
+
554
+ private fun placeInRegion(
555
+ pixels: Bitmap,
556
+ pixelsRegion: Rect,
557
+ region: Rect,
558
+ ): Bitmap {
559
+ if (pixelsRegion == region) {
560
+ return pixels
561
+ }
562
+ val bitmap = Bitmap.createBitmap(region.width(), region.height(), Bitmap.Config.ARGB_8888)
563
+ Canvas(bitmap).drawBitmap(
564
+ pixels,
565
+ (pixelsRegion.left - region.left).toFloat(),
566
+ (pixelsRegion.top - region.top).toFloat(),
567
+ null,
568
+ )
569
+ pixels.recycle()
570
+ return bitmap
571
+ }
572
+
573
+ // Each layer goes behind what is drawn already, so the layers are drawn last to first, stacking the SurfaceViews in
574
+ // the order of the view tree. The screen stacks a media overlay above the others, which no public API reports
575
+ // before Android 16, and the rest in the order their surfaces were created, which no API reports. Nothing is drawn
576
+ // outside the window's part of the region.
577
+ private fun drawBehind(
578
+ screenshot: Bitmap,
579
+ layers: List<SurfaceLayer>,
580
+ windowPart: Rect,
581
+ ) {
582
+ val canvas = Canvas(screenshot)
583
+ canvas.clipRect(windowPart)
584
+ val behind = Paint(Paint.FILTER_BITMAP_FLAG).apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OVER) }
585
+ layers.asReversed().forEach { layer ->
586
+ canvas.drawBitmap(layer.pixels, null, layer.box, behind)
587
+ layer.pixels.recycle()
588
+ }
589
+ }
590
+
591
+ private class SurfaceLayer(
592
+ val pixels: Bitmap,
593
+ val box: Rect,
594
+ )
595
+
596
+ private class SurfaceViewInWindow(
597
+ val view: SurfaceView,
598
+ val rect: Rect,
599
+ )
600
+
601
+ private fun collectSurfaceViews(
602
+ view: View,
603
+ found: MutableList<SurfaceViewInWindow> = mutableListOf(),
604
+ ): List<SurfaceViewInWindow> {
605
+ when {
606
+ view.visibility != View.VISIBLE -> Unit
607
+ view is SurfaceView ->
608
+ if (view.width > 0 && view.height > 0 && view.holder.surface.isValid) {
609
+ found.add(SurfaceViewInWindow(view, rectInWindow(view)))
610
+ }
611
+ view is ViewGroup ->
612
+ for (index in 0 until view.childCount) {
613
+ collectSurfaceViews(view.getChildAt(index), found)
614
+ }
615
+ }
616
+ return found
617
+ }
618
+
619
+ // A view's own space mapped into the window through its own and its ancestors' transforms, composed into one as
620
+ // the renderer composes them, then the pan the window applies to all of its views.
621
+ private fun viewToWindow(view: View): Matrix {
622
+ val transform = Matrix()
623
+ var current = view
624
+ while (true) {
625
+ transform.postConcat(current.matrix)
626
+ transform.postTranslate(current.left.toFloat(), current.top.toFloat())
627
+ val parent = current.parent as? View ?: break
628
+ transform.postTranslate(-parent.scrollX.toFloat(), -parent.scrollY.toFloat())
629
+ current = parent
630
+ }
631
+ // The root view's location in the window is its own corner, already composed above, rounded to whole pixels,
632
+ // plus the pan.
633
+ val rootLocation = IntArray(2)
634
+ current.getLocationInWindow(rootLocation)
635
+ val rootCorner = floatArrayOf(0f, 0f).also { corner -> current.matrix.mapPoints(corner) }
636
+ transform.postTranslate(
637
+ (rootLocation[0] - (rootCorner[0] + current.left).roundToInt()).toFloat(),
638
+ (rootLocation[1] - (rootCorner[1] + current.top).roundToInt()).toFloat(),
639
+ )
640
+ return transform
641
+ }
642
+
643
+ // The box a view covers in the window, as UIKit's convertRect:toView: gives it, rounded as the renderer rounds the
644
+ // box it shows a SurfaceView's surface in: snapped to pixel boundaries under translation alone, outward under any
645
+ // other transform. A box with no area stays empty whatever its rounding would give.
646
+ private fun rectInWindow(view: View): Rect {
647
+ val transform = viewToWindow(view)
648
+ val bounds = RectF(0f, 0f, view.width.toFloat(), view.height.toFloat()).also { rect -> transform.mapRect(rect) }
649
+ return when {
650
+ bounds.isEmpty -> Rect()
651
+ isTranslation(transform) -> snapToPixelBoundaries(bounds)
652
+ else -> Rect().also { rect -> bounds.roundOut(rect) }
653
+ }
654
+ }
655
+
656
+ // As the renderer snaps a box under translation alone: to the nearest pixel, and from Android 10
657
+ // (Rect::snapGeometryToPixelBoundaries) with an edge within GEOMETRY_FUDGE_FACTOR of a half pixel going outward.
658
+ private fun snapToPixelBoundaries(bounds: RectF): Rect {
659
+ val fudge = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) GEOMETRY_FUDGE_FACTOR else 0f
660
+ return Rect(
661
+ floor(bounds.left + 0.5f - fudge).toInt(),
662
+ floor(bounds.top + 0.5f - fudge).toInt(),
663
+ floor(bounds.right + 0.5f + fudge).toInt(),
664
+ floor(bounds.bottom + 0.5f + fudge).toInt(),
665
+ )
666
+ }
667
+
668
+ private fun isTranslation(matrix: Matrix): Boolean {
669
+ val values = FloatArray(9).also { matrix.getValues(it) }
670
+ return values[Matrix.MSCALE_X] == 1f &&
671
+ values[Matrix.MSCALE_Y] == 1f &&
672
+ values[Matrix.MSKEW_X] == 0f &&
673
+ values[Matrix.MSKEW_Y] == 0f &&
674
+ values[Matrix.MPERSP_0] == 0f &&
675
+ values[Matrix.MPERSP_1] == 0f
676
+ }
677
+
678
+ private fun encodePng(bitmap: Bitmap): String {
679
+ try {
680
+ val outputStream = ByteArrayOutputStream()
681
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
682
+ Log.i(TAG, "Screenshot captured successfully (${outputStream.size()} bytes)")
683
+ return android.util.Base64.encodeToString(outputStream.toByteArray(), android.util.Base64.NO_WRAP)
684
+ } finally {
685
+ bitmap.recycle()
686
+ }
687
+ }
688
+
354
689
  // =========================================================================
355
690
  // Text Input
356
691
  // =========================================================================
@@ -21,7 +21,9 @@ interface Spec extends TurboModule {
21
21
  queryAllByTestId(testId: string): ViewInfo[];
22
22
  queryByAccessibilityLabel(label: string): ViewInfo | null;
23
23
  queryAllByAccessibilityLabel(label: string): ViewInfo[];
24
- captureScreenshot(bounds: ViewInfo | null): Promise<string | null>;
24
+ captureScreenshot(options: {
25
+ bounds?: ViewInfo;
26
+ }): Promise<string | null>;
25
27
  typeChar(character: string): Promise<void>;
26
28
  blur(options: {
27
29
  submitEditing?: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"NativeHarnessUI.d.ts","sourceRoot":"","sources":["../src/NativeHarnessUI.ts"],"names":[],"mappings":"AAAA,OAAO,EAAuB,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAErE;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;GAGG;AACH,MAAM,WAAW,QAAS,SAAQ,WAAW;IAC3C,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,UAAU,IAAK,SAAQ,WAAW;IAChC,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrE,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAAC;IAC/C,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,EAAE,CAAC;IAC7C,yBAAyB,CAAC,KAAK,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAAC;IAC1D,4BAA4B,CAAC,KAAK,EAAE,MAAM,GAAG,QAAQ,EAAE,CAAC;IACxD,iBAAiB,CAAC,MAAM,EAAE,QAAQ,GAAG,IAAI,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACnE,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3C,IAAI,CAAC,OAAO,EAAE;QAAE,aAAa,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3D;;AAED,wBAAmE"}
1
+ {"version":3,"file":"NativeHarnessUI.d.ts","sourceRoot":"","sources":["../src/NativeHarnessUI.ts"],"names":[],"mappings":"AAAA,OAAO,EAAuB,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAErE;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;GAGG;AACH,MAAM,WAAW,QAAS,SAAQ,WAAW;IAC3C,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,UAAU,IAAK,SAAQ,WAAW;IAChC,aAAa,CAAC,QAAQ,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrE,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAAC;IAC/C,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ,EAAE,CAAC;IAC7C,yBAAyB,CAAC,KAAK,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAAC;IAC1D,4BAA4B,CAAC,KAAK,EAAE,MAAM,GAAG,QAAQ,EAAE,CAAC;IACxD,iBAAiB,CAAC,OAAO,EAAE;QAAE,MAAM,CAAC,EAAE,QAAQ,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC1E,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3C,IAAI,CAAC,OAAO,EAAE;QAAE,aAAa,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC3D;;AAED,wBAAmE"}
@@ -49,8 +49,8 @@ const WebHarnessUI = {
49
49
  const elements = document.querySelectorAll(`[aria-label="${label}"]`);
50
50
  return Array.from(elements).map(getElementViewInfo);
51
51
  },
52
- captureScreenshot: async (bounds) => {
53
- let captureBounds = bounds;
52
+ captureScreenshot: async ({ bounds }) => {
53
+ let captureBounds = bounds ?? null;
54
54
  if (bounds?.nativeId && bounds.width === 0 && bounds.height === 0) {
55
55
  const element = window.__RN_HARNESS_VIEW_REGISTRY__.get(bounds.nativeId);
56
56
  if (element) {
@@ -1 +1 @@
1
- {"version":3,"file":"screen.d.ts","sourceRoot":"","sources":["../src/screen.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,WAAW,EAAE,MAAM,YAAY,CAAC;AAI7D;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GAAG;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B,CAAC;AAMF;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,sDAAsD;IACtD,IAAI,EAAE,UAAU,CAAC;IACjB,gEAAgE;IAChE,KAAK,EAAE,MAAM,CAAC;IACd,iEAAiE;IACjE,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,MAAM,MAAM,GAAG;IACnB;;;OAGG;IACH,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAE5D;;;OAGG;IACH,eAAe,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAAC;IAEjE;;;OAGG;IACH,aAAa,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,gBAAgB,GAAG,IAAI,CAAC;IAE3D;;;OAGG;IACH,gBAAgB,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,gBAAgB,EAAE,CAAC;IAEzD;;;OAGG;IACH,wBAAwB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAEvE;;;OAGG;IACH,2BAA2B,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAAC;IAE5E;;;OAGG;IACH,yBAAyB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,gBAAgB,GAAG,IAAI,CAAC;IAEtE;;;OAGG;IACH,4BAA4B,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,gBAAgB,EAAE,CAAC;IAEpE;;;;OAIG;IACH,UAAU,EAAE,CACV,MAAM,CAAC,EAAE,gBAAgB,GAAG,WAAW,KACpC,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;CACvC,CAAC;AA8HF,eAAO,MAAM,MAAM,QAAiB,CAAC"}
1
+ {"version":3,"file":"screen.d.ts","sourceRoot":"","sources":["../src/screen.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,WAAW,EAAE,MAAM,YAAY,CAAC;AAI7D;;;GAGG;AACH,MAAM,MAAM,gBAAgB,GAAG;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B,CAAC;AAMF;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,sDAAsD;IACtD,IAAI,EAAE,UAAU,CAAC;IACjB,gEAAgE;IAChE,KAAK,EAAE,MAAM,CAAC;IACd,iEAAiE;IACjE,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,MAAM,MAAM,GAAG;IACnB;;;OAGG;IACH,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAE5D;;;OAGG;IACH,eAAe,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAAC;IAEjE;;;OAGG;IACH,aAAa,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,gBAAgB,GAAG,IAAI,CAAC;IAE3D;;;OAGG;IACH,gBAAgB,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,gBAAgB,EAAE,CAAC;IAEzD;;;OAGG;IACH,wBAAwB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,gBAAgB,CAAC,CAAC;IAEvE;;;OAGG;IACH,2BAA2B,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,gBAAgB,EAAE,CAAC,CAAC;IAE5E;;;OAGG;IACH,yBAAyB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,gBAAgB,GAAG,IAAI,CAAC;IAEtE;;;OAGG;IACH,4BAA4B,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,gBAAgB,EAAE,CAAC;IAEpE;;;;OAIG;IACH,UAAU,EAAE,CACV,MAAM,CAAC,EAAE,gBAAgB,GAAG,WAAW,KACpC,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAAC;CACvC,CAAC;AAgIF,eAAO,MAAM,MAAM,QAAiB,CAAC"}
package/dist/screen.js CHANGED
@@ -80,7 +80,7 @@ const createScreen = () => {
80
80
  targetHeight = target.height;
81
81
  }
82
82
  }
83
- const base64String = await HarnessUI.captureScreenshot(captureBounds);
83
+ const base64String = await HarnessUI.captureScreenshot(captureBounds ? { bounds: captureBounds } : {});
84
84
  if (!base64String) {
85
85
  return null;
86
86
  }