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.10.0 | Android `capture-runtime` `0.1.0`; Apple `TugboatCaptureRuntime` `0.1.1`; Device Farm launch inputs owned by the SDK; `hidden` + `paused` coalesce into one `app_backgrounded`. |
| Flutter `tugboat` | 0.9.0 | Android `capture-runtime` `0.1.0`; Apple `TugboatCaptureRuntime` `0.1.1`; mode-free capture API with additive evidence capabilities. |
| 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. |
Expand Down
19 changes: 19 additions & 0 deletions sdks/flutter/packages/tugboat/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
## 0.10.0

### Added

- Device Farm launch inputs owned by the SDK: `TugboatLaunchOptions`
reads Android Intent extras (`tugboat_emit_scene_inventory`,
`tugboat_accept_action_context`, `tugboat_collector_base_url`) and iOS
process environment (`TUGBOAT_EMIT_SCENE_INVENTORY`,
`TUGBOAT_ACCEPT_ACTION_CONTEXT`, `TUGBOAT_COLLECTOR_BASE_URL`) through the
`tugboat/launch` plugin channel, so host apps need no native code.
- `TugboatReplayConfig.withDeviceFarmOverrides()`: additive merge of launch
capabilities plus a release-guarded, local-only collector URL override
(`resolveTugboatCollectorBaseUrl`). Hosts collapse to a single call.

### Fixed

- Coalesce the back-to-back `hidden` + `paused` lifecycle callbacks into a
single `app_backgrounded` event instead of emitting one per state.

## 0.9.0

### Changed
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.tugboat.flutter

import android.app.Activity
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.view.View
Expand All @@ -18,6 +19,15 @@ import io.flutter.embedding.android.FlutterView
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodChannel

/// Device Farm launch extras read from the host `Intent`.
///
/// Natives pass raw strings through; all `1`/`true`/`yes` normalization and
/// local-URL validation lives in `TugboatLaunchParsers` on the Dart side.
const val TUGBOAT_EMIT_SCENE_INVENTORY = "tugboat_emit_scene_inventory"
const val TUGBOAT_ACCEPT_ACTION_CONTEXT = "tugboat_accept_action_context"
const val TUGBOAT_COLLECTOR_BASE_URL = "tugboat_collector_base_url"

class TugboatPlugin :
FlutterPlugin,
Expand All @@ -26,13 +36,25 @@ class TugboatPlugin :
private var runtime: CaptureRuntime? = null
private var activity: Activity? = null
private val mainHandler = Handler(Looper.getMainLooper())
private var launchChannel: MethodChannel? = null

override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
runtime = CaptureRuntime()
NativeCaptureHostApi.setUp(binding.binaryMessenger, this)
launchChannel = MethodChannel(binding.binaryMessenger, "tugboat/launch").also { channel ->
channel.setMethodCallHandler { call, result ->
if (call.method == "getLaunchOptions") {
result.success(launchOptions())
} else {
result.notImplemented()
}
}
}
}

override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
launchChannel?.setMethodCallHandler(null)
launchChannel = null
NativeCaptureHostApi.setUp(binding.binaryMessenger, null)
runtime?.dispose()
runtime = null
Expand Down Expand Up @@ -92,6 +114,22 @@ class TugboatPlugin :
return findFlutterView(root)
}

private fun launchOptions(): Map<String, Any?> {
val intent = activity?.intent
val extras = intent?.extras
return mapOf(
"emitSceneInventory" to rawExtra(extras, TUGBOAT_EMIT_SCENE_INVENTORY),
"acceptActionContext" to rawExtra(extras, TUGBOAT_ACCEPT_ACTION_CONTEXT),
"collectorBaseUrl" to (
intent?.getStringExtra(TUGBOAT_COLLECTOR_BASE_URL)
?: rawExtra(extras, TUGBOAT_COLLECTOR_BASE_URL)
),
)
}

private fun rawExtra(extras: Bundle?, name: String): String? =
extras?.get(name)?.toString()

private fun findFlutterView(view: View): View? {
if (view is FlutterView) return view
if (view is ViewGroup) {
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.9.0
tugboat: ^0.10.0

# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
Expand Down
41 changes: 41 additions & 0 deletions sdks/flutter/packages/tugboat/ios/Classes/TugboatPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ public class TugboatPlugin: NSObject, FlutterPlugin, NativeCaptureHostApi {
private var engineRuntime: CaptureRuntime?
private var hierarchyRuntime: CaptureRuntime?
private weak var registrar: FlutterPluginRegistrar?
private var launchChannel: FlutterMethodChannel?
private let callbackQueue = DispatchQueue.main
private let stateLock = NSLock()
private var disposed = false
Expand All @@ -14,9 +15,49 @@ public class TugboatPlugin: NSObject, FlutterPlugin, NativeCaptureHostApi {
let instance = TugboatPlugin()
instance.registrar = registrar
instance.engineRuntime = CaptureRuntime()
// Device Farm launch inputs from the runner's process environment.
// Raw strings pass through; Dart-side `TugboatLaunchParsers` normalizes.
let launchChannel = FlutterMethodChannel(
name: "tugboat/launch",
binaryMessenger: registrar.messenger()
)
instance.launchChannel = launchChannel
launchChannel.setMethodCallHandler { call, result in
guard call.method == "getLaunchOptions" else {
result(FlutterMethodNotImplemented)
return
}
let environment = ProcessInfo.processInfo.environment
result([
"emitSceneInventory": Self.launchValue(
environment,
key: "TUGBOAT_EMIT_SCENE_INVENTORY"
),
"acceptActionContext": Self.launchValue(
environment,
key: "TUGBOAT_ACCEPT_ACTION_CONTEXT"
),
"collectorBaseUrl": Self.launchValue(
environment,
key: "TUGBOAT_COLLECTOR_BASE_URL"
),
])
}
NativeCaptureHostApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance)
}

/// Reads one raw launch value. Absent keys encode as `NSNull` (Dart reads
/// both as off); `TugboatLaunchParsers` owns all normalization.
private static func launchValue(
_ environment: [String: String],
key: String
) -> Any {
if let value = environment[key] {
return value
}
return NSNull()
}

func getCapabilities() throws -> NativeCaptureCapabilities {
guard let runtime = engineCaptureRuntime() else {
return NativeCaptureCapabilities(
Expand Down
18 changes: 18 additions & 0 deletions sdks/flutter/packages/tugboat/lib/src/collector_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -205,4 +205,22 @@ class TugboatCollectorConfig {
maxPendingFrames: maxPendingFrames,
);
}

/// Returns a copy pointed at [baseUrl] (Device Farm local override).
TugboatCollectorConfig withBaseUrl(String baseUrl) {
return TugboatCollectorConfig(
baseUrl: baseUrl,
apiKey: apiKey,
userId: userId,
appInfo: appInfo,
deviceInfo: deviceInfo,
ipInfo: ipInfo,
locale: locale,
eventBatchSize: eventBatchSize,
eventFlushInterval: eventFlushInterval,
maxPendingBatches: maxPendingBatches,
maxPendingEvents: maxPendingEvents,
maxPendingFrames: maxPendingFrames,
);
}
}
23 changes: 22 additions & 1 deletion sdks/flutter/packages/tugboat/lib/src/controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1090,6 +1090,8 @@ class TugboatReplayController extends ChangeNotifier {
bool _skipCapture = false;
bool _captureLifecycleActive = true;
int _captureLifecycleEpoch = 0;
AppLifecycleState? _lastLifecycleState;
String? _lastLifecycleEventType;
int _routeEpoch = 0;
final Map<String, _RouteCaptureWork> _activeRouteCaptures =
<String, _RouteCaptureWork>{};
Expand Down Expand Up @@ -1655,6 +1657,8 @@ class TugboatReplayController extends ChangeNotifier {
_clearReleasedInteractions(reason: InteractionRejectionReason.sessionEnd);
_captureLifecycleActive = true;
_captureLifecycleEpoch++;
_lastLifecycleState = null;
_lastLifecycleEventType = null;
_endSessionFuture = null;
_clock
..reset()
Expand Down Expand Up @@ -5151,6 +5155,17 @@ class TugboatReplayController extends ChangeNotifier {
}

void recordAppLifecycleState(AppLifecycleState state) {
final eventType = _appLifecycleEventType(state);
// Flutter emits hidden + paused back-to-back on every background
// transition (inactive -> hidden -> paused). Both map to
// app_backgrounded, so without dedup each background produces two
// identical events at the same atMs (see pmkit.raw_events). Drop exact
// repeats and consecutive same-type events; a new event is only emitted
// on an effective transition (e.g. backgrounded -> foregrounded ->
// backgrounded still emits twice, correctly).
final isDuplicate =
state == _lastLifecycleState ||
eventType == _lastLifecycleEventType;
switch (state) {
case AppLifecycleState.paused:
case AppLifecycleState.hidden:
Expand Down Expand Up @@ -5186,11 +5201,17 @@ class TugboatReplayController extends ChangeNotifier {
case AppLifecycleState.inactive:
break;
}
_lastLifecycleState = state;
if (isDuplicate) {
_lastLifecycleEventType = eventType;
return;
}
_lastLifecycleEventType = eventType;
_addEvent(
TugboatEvent(
id: _nextId('event'),
atMs: atMs,
type: _appLifecycleEventType(state),
type: eventType,
data: {'state': state.name},
),
);
Expand Down
Loading
Loading