Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
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 |
| --- | --- | --- |
| Flutter `tugboat` | 0.8.17 | Android `capture-runtime` `0.1.0`; Apple `TugboatCaptureRuntime` `0.1.1`; blank iOS engine captures retry with explicit hierarchy coverage. |
| Apple `TugboatCaptureRuntime` | 0.1.1 | Rejects transparent and near-white captures and validates explicit view-hierarchy capture before encoding. |
| 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. |
Expand Down
15 changes: 15 additions & 0 deletions sdks/flutter/packages/tugboat/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,18 @@
## 0.8.17

### Fixed

- Reject blank iOS engine-surface captures and retry with explicit hierarchy
capture before falling back to Flutter capture.
- Resolve the live `FlutterView` without selecting an unrelated controller
view.

### Changed

- Raise the default degraded capture scale from `0.67` to `0.80` to retain
more screenshot detail when the capture budget is degraded.
- Depend on CocoaPods `TugboatCaptureRuntime` `0.1.1`.

## 0.8.16

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion sdks/flutter/packages/tugboat/android/build.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
group = "com.tugboat.flutter"
version = "0.8.16"
version = "0.8.17"

buildscript {
ext.kotlin_version = "2.2.20"
Expand Down
2 changes: 1 addition & 1 deletion sdks/flutter/packages/tugboat/example/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ resolution: workspace
dependencies:
flutter:
sdk: flutter
tugboat: ^0.8.16
tugboat: ^0.8.17

# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
Expand Down
161 changes: 135 additions & 26 deletions sdks/flutter/packages/tugboat/ios/Classes/TugboatPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,65 +3,162 @@ import TugboatCaptureRuntime
import UIKit

public class TugboatPlugin: NSObject, FlutterPlugin, NativeCaptureHostApi {
private var runtime: CaptureRuntime?
private var engineRuntime: CaptureRuntime?
private var hierarchyRuntime: CaptureRuntime?
private weak var registrar: FlutterPluginRegistrar?
private let callbackQueue = DispatchQueue.main
private let stateLock = NSLock()
private var disposed = false

public static func register(with registrar: FlutterPluginRegistrar) {
let instance = TugboatPlugin()
instance.registrar = registrar
instance.runtime = CaptureRuntime()
instance.engineRuntime = CaptureRuntime()
NativeCaptureHostApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance)
}

func getCapabilities() throws -> NativeCaptureCapabilities {
NativeCaptureMapping.capabilities(requireRuntime().capabilities())
guard let runtime = engineCaptureRuntime() else {
return NativeCaptureCapabilities(
nativeCaptureSupported: false,
apiLevel: Int64(ProcessInfo.processInfo.operatingSystemVersion.majorVersion),
minNativeApi: Int64(CaptureRuntime.minNativeApi)
)
}
return NativeCaptureMapping.capabilities(runtime.capabilities())
}

func capture(
request: NativeCaptureRequest,
completion: @escaping (Result<NativeCaptureResult, Error>) -> Void
) {
guard let runtime = engineCaptureRuntime() else {
complete(NativeCaptureMapping.failed(request, .disposed), completion: completion)
return
}
guard let view = flutterView() else {
completion(.success(NativeCaptureMapping.failed(request, .surfaceUnavailable)))
complete(
NativeCaptureMapping.failed(request, .surfaceUnavailable),
completion: completion
)
return
}
requireRuntime().capture(view: view, request: NativeCaptureMapping.request(request)) { result in
self.callbackQueue.async {
completion(.success(NativeCaptureMapping.result(result)))
let started = DispatchTime.now()
let nativeRequest = NativeCaptureMapping.request(request)
runtime.capture(view: view, request: nativeRequest) { result in
guard result.status == .pixelCopyFailed else {
self.complete(result, completion: completion)
return
}

let remainingMs = self.remainingTimeoutMs(since: started)
guard remainingMs > 0 else {
self.complete(NativeCaptureMapping.failed(request, .timeout), completion: completion)
return
}
guard let hierarchyRuntime = self.makeHierarchyRuntime(timeoutMs: remainingMs) else {
self.complete(NativeCaptureMapping.failed(request, .disposed), completion: completion)
return
}
hierarchyRuntime.capture(view: view, request: nativeRequest) { retry in
self.complete(retry, completion: completion)
}
}
}

func cancel(requestId: Int64) throws {
runtime?.cancel(requestId: requestId)
let runtimes = captureRuntimes()
runtimes.engine?.cancel(requestId: requestId)
runtimes.hierarchy?.cancel(requestId: requestId)
}

func dispose() throws {
runtime?.dispose()
runtime = nil
stateLock.lock()
disposed = true
let engine = engineRuntime
let hierarchy = hierarchyRuntime
engineRuntime = nil
hierarchyRuntime = nil
stateLock.unlock()
engine?.dispose()
hierarchy?.dispose()
}

private func requireRuntime() -> CaptureRuntime {
if let runtime {
return runtime
private func complete(
_ result: CaptureResult,
completion: @escaping (Result<NativeCaptureResult, Error>) -> Void
) {
complete(NativeCaptureMapping.result(result), completion: completion)
}

private func complete(
_ result: NativeCaptureResult,
completion: @escaping (Result<NativeCaptureResult, Error>) -> Void
) {
callbackQueue.async {
completion(.success(result))
}
}

private func engineCaptureRuntime() -> CaptureRuntime? {
stateLock.lock()
defer { stateLock.unlock() }
guard !disposed else { return nil }
if let engineRuntime {
return engineRuntime
}
let created = CaptureRuntime()
runtime = created
engineRuntime = created
return created
}

private func flutterView() -> UIView? {
guard let controller = hostViewController() else {
/// The controller serializes capture requests. Replace the prior retry runtime
/// so this attempt uses only the time left in the original request budget.
private func makeHierarchyRuntime(timeoutMs: Int64) -> CaptureRuntime? {
stateLock.lock()
guard !disposed else {
stateLock.unlock()
return nil
}
return findFlutterView(controller.view) ?? controller.view
let created = CaptureRuntime(timeoutMs: timeoutMs, coverage: .viewHierarchy)
let previous = hierarchyRuntime
hierarchyRuntime = created
stateLock.unlock()
previous?.dispose()
return created
}

private func captureRuntimes() -> (engine: CaptureRuntime?, hierarchy: CaptureRuntime?) {
stateLock.lock()
defer { stateLock.unlock() }
return (engineRuntime, hierarchyRuntime)
}

private func remainingTimeoutMs(since started: DispatchTime) -> Int64 {
let elapsedMs = Int64(
(DispatchTime.now().uptimeNanoseconds - started.uptimeNanoseconds) / 1_000_000
)
return max(0, CaptureRuntime.defaultTimeoutMs - elapsedMs)
}

private func flutterView() -> UIView? {
if let controller = registrarViewController(),
let view = findFlutterView(controller.view)
{
return view
}

for window in foregroundWindows() {
if let view = findFlutterView(window) {
return view
}
}
return nil
}

/// Flutter 3.38+ exposes `registrar.viewController`. The package floor is
/// Flutter 3.35, which does not declare that property, so look it up at
/// runtime and fall back to the key window.
private func hostViewController() -> UIViewController? {
/// Flutter 3.35, which does not declare that property, so look it up at runtime.
private func registrarViewController() -> UIViewController? {
if let registrar {
let selector = NSSelectorFromString("viewController")
if registrar.responds(to: selector),
Expand All @@ -71,15 +168,27 @@ public class TugboatPlugin: NSObject, FlutterPlugin, NativeCaptureHostApi {
return controller
}
}
return keyWindowRootViewController()
return nil
}

private func keyWindowRootViewController() -> UIViewController? {
let windows = UIApplication.shared.connectedScenes
/// Prefer the active key window. Do not use an arbitrary controller view when
/// FlutterView is absent because that can produce a valid but unrelated frame.
private func foregroundWindows() -> [UIWindow] {
let scenes = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(\.windows)
let keyWindow = windows.first(where: \.isKeyWindow) ?? windows.first
return keyWindow?.rootViewController
let activeScenes = scenes.filter { $0.activationState == .foregroundActive }
let inactiveScenes = scenes.filter { $0.activationState == .foregroundInactive }

return (activeScenes + inactiveScenes).flatMap { scene in
scene.windows
.filter { !$0.isHidden && $0.alpha > 0 }
.sorted { left, right in
if left.isKeyWindow != right.isKeyWindow {
return left.isKeyWindow
}
return left.windowLevel.rawValue < right.windowLevel.rawValue
}
}
}

private func findFlutterView(_ view: UIView) -> UIView? {
Expand Down
4 changes: 2 additions & 2 deletions sdks/flutter/packages/tugboat/ios/tugboat.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
#
Pod::Spec.new do |s|
s.name = 'tugboat'
s.version = '0.8.16'
s.version = '0.8.17'
s.summary = 'Screenshot-based session replay with compact interaction anchors for Tugboat.'
s.description = <<-DESC
Flutter adapter for Tugboat session replay. Native CPU capture is experimental
Expand All @@ -17,7 +17,7 @@ depends on CocoaPods TugboatCaptureRuntime and requires iOS 15.
s.author = { 'Tugboat' => 'dev@bijatech.com' }
s.source = { :path => '.' }
s.dependency 'Flutter'
s.dependency 'TugboatCaptureRuntime', '0.1.0'
s.dependency 'TugboatCaptureRuntime', '0.1.1'
s.platform = :ios, '15.0'
s.static_framework = true
s.swift_version = '5.9'
Expand Down
2 changes: 1 addition & 1 deletion sdks/flutter/packages/tugboat/lib/src/replay_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ class TugboatReplayConfig {
this.capturePixelRatio = 0.75,
this.captureMaxWidth,
this.captureMaxHeight,
this.degradedCaptureScale = 0.67,
this.degradedCaptureScale = 0.80,
this.enableGlobalPointerCapture = true,
this.explorationCollectorUrl,
this.explorationRunId,
Expand Down
2 changes: 1 addition & 1 deletion sdks/flutter/packages/tugboat/lib/src/sdk_version.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// Keep this in sync with this package's pubspec.yaml. The SDK version test
// reads pubspec.yaml directly so release bumps fail fast if this drifts.
const tugboatSdkVersion = '0.8.16';
const tugboatSdkVersion = '0.8.17';
2 changes: 1 addition & 1 deletion sdks/flutter/packages/tugboat/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: tugboat
description: >-
Screenshot-based session replay with compact interaction anchors for Tugboat.
version: 0.8.16
version: 0.8.17
repository: https://github.com/blendto/tugboat-flutter
issue_tracker: https://github.com/blendto/tugboat-flutter/issues
homepage: https://github.com/blendto/tugboat-flutter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,34 @@ void main() {
},
);

test('native hierarchy retry reports its actual coverage', () async {
final api = _FakeHostApi(
captureHandler: (request) async => nativeCaptureResult(
requestId: request.requestId,
status: NativeCaptureStatus.ok,
jpeg: Uint8List.fromList(const [1, 2, 3, 4]),
width: 8,
height: 8,
dHash: '1' * 64,
contentHash: 'abc',
coverage: NativeCaptureCoverage.viewHierarchy,
),
);
final fallback = _RecordingFallback();
final source = NativeCpuExperimentalPixelSource(
client: NativeCaptureClient(api: api),
fallback: fallback,
);

final result = await source.acquire(
_pixelRequest(boundary: RenderRepaintBoundary()),
);

expect(fallback.calls, 0);
expect(result.disposition, ScreenshotPixelDisposition.captured);
expect(result.trace.coverage, 'viewHierarchy');
});

test('native fallback status uses Flutter path once', () async {
final api = _FakeHostApi(
captureHandler: (request) async => nativeCaptureResult(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,36 @@ void main() {
expect(capture.result!.height, 160);
});

testWidgets('default degraded scale retains more screenshot detail', (
tester,
) async {
const config = TugboatReplayConfig();
final boundaryKey = GlobalKey();
final capturer = ScreenshotCapturer(
boundaryKey: boundaryKey,
maskLevel: TugboatScreenshotMaskLevel.explicitOnly,
anchorResolver: AnchorResolver(rootKey: boundaryKey),
pixelRatio: config.capturePixelRatio,
degradedScale: config.degradedCaptureScale,
frameWaiter: () => Future<void>.value(),
encoder: InlineScreenshotEncoder(),
);
addTearDown(capturer.dispose);
await tester.pumpWidget(_scene(boundaryKey, Colors.red));

final degraded = await tester.runAsync(
() => capturer.captureAttempt(force: true, degraded: true),
);

expect(degraded, isNotNull);
expect(degraded!.result, isNotNull);
final expectedRatio =
config.capturePixelRatio * config.degradedCaptureScale;
final expectedDimension = (80 * expectedRatio).ceil();
expect(degraded.result!.width, expectedDimension);
expect(degraded.result!.height, expectedDimension);
});

test('copyWith can clear screenshot dimension bounds', () {
const bounded = TugboatReplayConfig(
captureMaxWidth: 540,
Expand Down
7 changes: 7 additions & 0 deletions sdks/flutter/packages/tugboat_dio/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## 0.8.17

### Changed

- Compatibility release for `tugboat` 0.8.17. The Dio adapter has no runtime
behavior change.

## 0.8.16

### Changed
Expand Down
Loading
Loading