@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.
- package/Package.swift +40 -0
- package/README.md +7 -0
- package/android/src/main/java/com/harnessui/HarnessUIModule.kt +2 -1
- package/android/src/main/java/com/harnessui/UIHelperImpl.kt +409 -74
- package/dist/NativeHarnessUI.d.ts +3 -1
- package/dist/NativeHarnessUI.d.ts.map +1 -1
- package/dist/WebHarnessUI.js +2 -2
- package/dist/screen.d.ts.map +1 -1
- package/dist/screen.js +1 -1
- package/dist/types.d.ts +6 -4
- package/dist/types.d.ts.map +1 -1
- package/ios/HarnessUI.h +2 -0
- package/ios/HarnessUI.mm +6 -4
- package/ios/ViewQueryHelper.mm +16 -14
- package/package.json +6 -2
- package/src/NativeHarnessUI.ts +1 -1
- package/src/WebHarnessUI.ts +2 -2
- package/src/screen.ts +3 -1
- package/src/types.ts +4 -4
- package/dist/HarnessUI.d.ts +0 -17
- package/dist/HarnessUI.d.ts.map +0 -1
- package/dist/HarnessUI.js +0 -2
- package/dist/HarnessUI.web.d.ts +0 -14
- package/dist/HarnessUI.web.d.ts.map +0 -1
- package/dist/HarnessUI.web.js +0 -46
- package/dist/NativeHarness.d.ts +0 -22
- package/dist/NativeHarness.d.ts.map +0 -1
- package/dist/NativeHarness.js +0 -2
- package/dist/WebHarness.d.ts +0 -14
- package/dist/WebHarness.d.ts.map +0 -1
- package/dist/WebHarness.js +0 -53
- package/dist/harness/NativeHarness.d.ts +0 -17
- package/dist/harness/NativeHarness.d.ts.map +0 -1
- package/dist/harness/NativeHarness.js +0 -2
- package/dist/harness/WebHarness.d.ts +0 -14
- package/dist/harness/WebHarness.d.ts.map +0 -1
- package/dist/harness/WebHarness.js +0 -53
- package/dist/harness/index.d.ts +0 -4
- package/dist/harness/index.d.ts.map +0 -1
- package/dist/harness/index.js +0 -8
- package/dist/harness/native-harness.d.ts +0 -17
- package/dist/harness/native-harness.d.ts.map +0 -1
- package/dist/harness/native-harness.js +0 -2
- package/dist/harness/web-harness.d.ts +0 -14
- package/dist/harness/web-harness.d.ts.map +0 -1
- 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
|
-
|
|
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
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
val pngBytes = outputStream.toByteArray()
|
|
290
|
-
bitmap.recycle()
|
|
286
|
+
val width = targetView.width
|
|
287
|
+
val height = targetView.height
|
|
291
288
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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.
|
|
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
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
|
|
333
|
-
|
|
374
|
+
fun failCopy(reason: String) {
|
|
375
|
+
copyFailure = copyFailure ?: reason
|
|
376
|
+
}
|
|
334
377
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
378
|
+
fun onCopyFinished() {
|
|
379
|
+
pendingCopies -= 1
|
|
380
|
+
if (pendingCopies > 0) {
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
val layers = surfaceLayers.filterNotNull()
|
|
339
384
|
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
Log.
|
|
346
|
-
|
|
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
|
-
|
|
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(
|
|
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,
|
|
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"}
|
package/dist/WebHarnessUI.js
CHANGED
|
@@ -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) {
|
package/dist/screen.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
}
|