Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion TugboatCaptureRuntime.podspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'TugboatCaptureRuntime'
s.version = '0.1.0'
s.version = '0.1.1'
s.summary = 'Tugboat native CPU screenshot capture for Apple platforms.'
s.description = <<-DESC
Experimental Apple capture runtime. Renders the live Flutter layer into a
Expand Down
1 change: 1 addition & 0 deletions docs/releases/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

| Adapter | Adapter version | Native runtime |
| --- | --- | --- |
| Apple `TugboatCaptureRuntime` | 0.1.1 | Rejects transparent and near-white engine-surface results, retries native view-hierarchy capture, and reports the coverage that produced the returned frame. |
Comment thread
Chinmay-KB marked this conversation as resolved.
Outdated
| Flutter `tugboat` | 0.8.16 | Same hosted runtimes as 0.8.15. iOS plugin looks up `registrar.viewController` at runtime so Flutter 3.35 hosts compile. |
| Flutter `tugboat` | 0.8.15 | Android `com.gettugboat.sdk:capture-runtime:0.1.0` from Maven Central. Apple `TugboatCaptureRuntime` `0.1.0` from CocoaPods trunk. Plugin iOS floor 15. |
| Flutter `tugboat` | 0.8.14 | Android `com.gettugboat.sdk:capture-runtime:0.1.0` from Maven Central. Apple `TugboatCaptureRuntime` 0.1.x compiled from monorepo sources (unpublished CocoaPod is not required). Plugin iOS floor 12; native capture still reports unsupported below iOS 15. |
Expand Down
22 changes: 14 additions & 8 deletions platforms/apple/Sources/TugboatCaptureRuntime/CaptureRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,13 @@ public final class CaptureRuntime {
}

let started = DispatchTime.now()
let bitmap = drawOnMain(view: view, request: request)
let drawOutcome = drawOnMain(view: view, request: request)
let copyTimings = CaptureTimings(surfaceCopyMicros: elapsedMicros(from: started))
if let early = haltIfNeeded(request, started, copyTimings) {
return early
}
guard let bitmap else {
let status: CaptureStatus =
view.bounds.width <= 0 || view.bounds.height <= 0 ? .surfaceUnavailable : .pixelCopyFailed
guard let bitmap = drawOutcome.bitmap else {
let status: CaptureStatus = drawOutcome.hasSurface ? .pixelCopyFailed : .surfaceUnavailable
return reply(request, status, timings: copyTimings)
}

Expand All @@ -102,7 +101,7 @@ public final class CaptureRuntime {
return reply(
request,
.skippedByDHash,
coverage: coverage,
coverage: bitmap.coverage,
width: bitmap.width,
height: bitmap.height,
dHash: processed.dHash,
Expand Down Expand Up @@ -131,7 +130,7 @@ public final class CaptureRuntime {
return reply(
request,
.ok,
coverage: coverage,
coverage: bitmap.coverage,
jpeg: jpeg,
width: bitmap.width,
height: bitmap.height,
Expand All @@ -142,9 +141,16 @@ public final class CaptureRuntime {
)
}

private func drawOnMain(view: UIView, request: CaptureRequest) -> NativeBitmap? {
private func drawOnMain(
view: UIView,
request: CaptureRequest
) -> (bitmap: NativeBitmap?, hasSurface: Bool) {
var bitmap: NativeBitmap?
var hasSurface = false
let draw = {
view.layoutIfNeeded()
hasSurface = view.bounds.width > 0 && view.bounds.height > 0
guard hasSurface else { return }
bitmap = AppleViewCapture.capture(
view: view,
pixelWidth: request.pixelWidth,
Expand All @@ -157,7 +163,7 @@ public final class CaptureRuntime {
} else {
DispatchQueue.main.sync(execute: draw)
}
return bitmap
return (bitmap, hasSurface)
}

private func haltIfNeeded(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ final class NativeBitmap {
let height: Int
let stride: Int
let pixels: UnsafeMutableRawPointer
let coverage: CaptureCoverage
let incomplete: Bool
private var context: CGContext?

Expand All @@ -15,16 +16,36 @@ final class NativeBitmap {
stride: Int,
pixels: UnsafeMutableRawPointer,
context: CGContext,
coverage: CaptureCoverage,
incomplete: Bool
) {
self.width = width
self.height = height
self.stride = stride
self.pixels = pixels
self.coverage = coverage
self.context = context
self.incomplete = incomplete
}

/// Reject untouched transparent buffers and the opaque white frames that the
/// Flutter Metal path can return when it does not copy the rendered surface.
Comment thread
Chinmay-KB marked this conversation as resolved.
Outdated
var hasCapturedContent: Bool {
let bytes = pixels.assumingMemoryBound(to: UInt8.self)
for y in 0..<height {
let row = bytes.advanced(by: y * stride)
for x in 0..<width {
let pixel = row.advanced(by: x * 4)
let isVisible = pixel[3] != 0
let isNearWhite = pixel[0] >= 250 && pixel[1] >= 250 && pixel[2] >= 250
if isVisible && !isNearWhite {
return true
}
}
}
return false
Comment thread
Chinmay-KB marked this conversation as resolved.
}

func jpegData() -> Data? {
guard let context else { return nil }
return JpegEncoder.encode(from: context)
Expand All @@ -42,6 +63,65 @@ enum AppleViewCapture {
pixelWidth: Int,
pixelHeight: Int,
coverage: CaptureCoverage
) -> NativeBitmap? {
switch coverage {
case .engineSurface:
if let bitmap = captureOnce(
view: view,
pixelWidth: pixelWidth,
pixelHeight: pixelHeight,
coverage: .engineSurface,
afterScreenUpdates: false
), bitmap.hasCapturedContent {
return bitmap
}
return captureHierarchy(
view: view,
pixelWidth: pixelWidth,
pixelHeight: pixelHeight
)
Comment thread
Chinmay-KB marked this conversation as resolved.
Outdated
case .viewHierarchy:
return captureHierarchy(
view: view,
pixelWidth: pixelWidth,
pixelHeight: pixelHeight
)
}
}

private static func captureHierarchy(
view: UIView,
pixelWidth: Int,
pixelHeight: Int
) -> NativeBitmap? {
var incompleteCapture: NativeBitmap?
for afterScreenUpdates in [false, true] {
guard
let bitmap = captureOnce(
view: view,
pixelWidth: pixelWidth,
pixelHeight: pixelHeight,
coverage: .viewHierarchy,
afterScreenUpdates: afterScreenUpdates
),
bitmap.hasCapturedContent
else {
continue
}
if !bitmap.incomplete {
return bitmap
}
incompleteCapture = bitmap
}
return incompleteCapture
}

private static func captureOnce(
view: UIView,
pixelWidth: Int,
pixelHeight: Int,
coverage: CaptureCoverage,
afterScreenUpdates: Bool
) -> NativeBitmap? {
let bounds = view.bounds
if bounds.width <= 0 || bounds.height <= 0 {
Expand Down Expand Up @@ -75,13 +155,20 @@ enum AppleViewCapture {
x: CGFloat(pixelWidth) / bounds.width,
y: -CGFloat(pixelHeight) / bounds.height
)
let incomplete = render(view: view, bounds: bounds, context: context, coverage: coverage)
let incomplete = render(
view: view,
bounds: bounds,
context: context,
coverage: coverage,
afterScreenUpdates: afterScreenUpdates
)
return NativeBitmap(
width: pixelWidth,
height: pixelHeight,
stride: stride,
pixels: pixels,
context: context,
coverage: coverage,
incomplete: incomplete
)
}
Expand All @@ -90,19 +177,20 @@ enum AppleViewCapture {
view: UIView,
bounds: CGRect,
context: CGContext,
coverage: CaptureCoverage
coverage: CaptureCoverage,
afterScreenUpdates: Bool
) -> Bool {
switch coverage {
case .engineSurface:
// FlutterView implements CALayerDelegate. Rendering the live layer is
// the experimental CPU path. Core Graphics does not copy CAMetalLayer
// contents by itself; physical Metal coverage is a device-lab gate.
// snapshotView copies lose the engine delegate and go blank.
// the fast CPU path. Core Graphics does not copy CAMetalLayer contents
// by itself. If the result is blank, capture() retries with drawHierarchy
// instead of publishing an empty frame.
view.layer.render(in: context)
return false
case .viewHierarchy:
UIGraphicsPushContext(context)
let drawn = view.drawHierarchy(in: bounds, afterScreenUpdates: false)
let drawn = view.drawHierarchy(in: bounds, afterScreenUpdates: afterScreenUpdates)
UIGraphicsPopContext()
return !drawn
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,64 @@ final class CaptureRuntimeModeTests: XCTestCase {
}

func testHierarchyCompatibilityCaptureReportsViewHierarchyCoverage() throws {
let result = try capture(runtime: CaptureRuntime(coverage: .viewHierarchy))
let view = HierarchyDrawingView(
frame: CGRect(x: 0, y: 0, width: 40, height: 60),
fillColor: .green
)

let result = try capture(runtime: CaptureRuntime(coverage: .viewHierarchy), view: view)

XCTAssertEqual(result.coverage, .viewHierarchy)
XCTAssertFalse(result.jpeg.isEmpty)
}

func testEngineSurfaceRetriesHierarchyWhenLayerWritesNoPixels() throws {
let view = HierarchyDrawingView(
frame: CGRect(x: 0, y: 0, width: 40, height: 60),
fillColor: .green
)

let result = try capture(runtime: CaptureRuntime(), view: view)

XCTAssertEqual(result.status, .ok)
XCTAssertEqual(result.coverage, .viewHierarchy)
XCTAssertFalse(result.jpeg.isEmpty)
XCTAssertFalse(result.incomplete)
}

func testTransparentCaptureFailsInsteadOfPublishingBlankJpeg() throws {
let view = HierarchyDrawingView(
frame: CGRect(x: 0, y: 0, width: 40, height: 60),
fillColor: .clear
)

let result = try capture(runtime: CaptureRuntime(), view: view)

XCTAssertEqual(result.status, .pixelCopyFailed)
XCTAssertNil(result.coverage)
XCTAssertTrue(result.jpeg.isEmpty)
}

func testOpaqueWhiteCaptureFailsInsteadOfPublishingBlankJpeg() throws {
let view = HierarchyDrawingView(
frame: CGRect(x: 0, y: 0, width: 40, height: 60),
fillColor: .white
)

let result = try capture(runtime: CaptureRuntime(), view: view)

XCTAssertEqual(result.status, .pixelCopyFailed)
XCTAssertNil(result.coverage)
XCTAssertTrue(result.jpeg.isEmpty)
}

private func capture(runtime: CaptureRuntime) throws -> CaptureResult {
let view = UIView(frame: CGRect(x: 0, y: 0, width: 40, height: 60))
view.backgroundColor = .red
return try capture(runtime: runtime, view: view)
}

private func capture(runtime: CaptureRuntime, view: UIView) throws -> CaptureResult {
let completed = expectation(description: "capture completes")
var captured: CaptureResult?

Expand All @@ -42,3 +91,30 @@ final class CaptureRuntimeModeTests: XCTestCase {
return try XCTUnwrap(captured)
}
}

private final class NoRenderLayer: CALayer {
override func render(in context: CGContext) {}
}

private final class HierarchyDrawingView: UIView {
private let fillColor: UIColor

init(frame: CGRect, fillColor: UIColor) {
self.fillColor = fillColor
super.init(frame: frame)
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override class var layerClass: AnyClass {
NoRenderLayer.self
}

override func drawHierarchy(in rect: CGRect, afterScreenUpdates: Bool) -> Bool {
fillColor.setFill()
UIRectFill(rect)
return true
}
}
Loading