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
136 changes: 136 additions & 0 deletions apps/bridge/lib/inspector/flutter_inspector_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,16 @@ class VmServiceFlutterInspectorClient implements FlutterInspectorClient {
);
}

final boundsById = await _fetchWidgetBoundsById(
vmService,
isolateId: isolateId,
diagnosticsNode: result,
);

return WidgetTreeNode.fromFlutterDiagnostics(
result,
projectRoot: session.projectRoot,
boundsById: boundsById,
);
} finally {
await vmService.dispose();
Expand Down Expand Up @@ -303,6 +310,123 @@ class VmServiceFlutterInspectorClient implements FlutterInspectorClient {
}
}

Future<Map<String, WidgetBounds>> _fetchWidgetBoundsById(
FlutterInspectorVmService vmService, {
required String isolateId,
required Map<String, Object?> diagnosticsNode,
}) async {
final boundsById = <String, WidgetBounds>{};

for (final widgetId in _widgetIdsInDiagnostics(diagnosticsNode)) {
final WidgetBounds? bounds;
try {
bounds = await _fetchWidgetBounds(
vmService,
isolateId: isolateId,
widgetId: widgetId,
);
} on _WidgetBoundsExtensionUnavailable {
break;
}
if (bounds == null) {
continue;
}
boundsById[widgetId] = bounds;
}

return boundsById;
}

Iterable<String> _widgetIdsInDiagnostics(
Map<String, Object?> diagnosticsNode,
) sync* {
final id = diagnosticsNode['valueId']?.toString().trim();
if (id != null && id.isNotEmpty) {
yield id;
}

final rawChildren = diagnosticsNode['children'];
if (rawChildren is! List) {
return;
}

for (final child in rawChildren.whereType<Map<String, Object?>>()) {
yield* _widgetIdsInDiagnostics(child);
}
}

Future<WidgetBounds?> _fetchWidgetBounds(
FlutterInspectorVmService vmService, {
required String isolateId,
required String widgetId,
}) async {
final response = await _callWidgetBoundsExtension(
vmService,
isolateId: isolateId,
widgetId: widgetId,
);
if (response == null) {
return null;
}

final result = _decodeInspectorResult(response);
if (result is! Map<String, Object?>) {
return null;
}

return _widgetBoundsFromAppSideResult(result);
}

Future<Map<String, Object?>?> _callWidgetBoundsExtension(
FlutterInspectorVmService vmService, {
required String isolateId,
required String widgetId,
}) async {
try {
return await vmService.callServiceExtension(
'ext.ask_ui.widgetBounds',
isolateId: isolateId,
args: {
'id': widgetId,
'groupName': 'ask_ui_widget_tree',
},
);
} on FlutterInspectorServiceUnavailableException {
throw const _WidgetBoundsExtensionUnavailable();
} on RPCError catch (error) {
if (error.code == RPCErrorKind.kMethodNotFound.code) {
throw const _WidgetBoundsExtensionUnavailable();
}
rethrow;
}
}

WidgetBounds? _widgetBoundsFromAppSideResult(Map<String, Object?> result) {
final x = _doubleFrom(result['x']);
final y = _doubleFrom(result['y']);
final width = _doubleFrom(result['width']);
final height = _doubleFrom(result['height']);
final devicePixelRatio = _doubleFrom(result['devicePixelRatio']) ?? 1;

if (x == null || y == null || width == null || height == null) {
return null;
}
if (width <= 0 || height <= 0 || devicePixelRatio <= 0) {
return null;
}

return WidgetBounds(
x: x * devicePixelRatio,
y: y * devicePixelRatio,
width: width * devicePixelRatio,
height: height * devicePixelRatio,
);
}

class _WidgetBoundsExtensionUnavailable implements Exception {
const _WidgetBoundsExtensionUnavailable();
}

Object? _decodeInspectorResult(Map<String, Object?> response) {
final result = response['result'] ?? response['object'] ?? response['value'];

Expand All @@ -313,6 +437,18 @@ Object? _decodeInspectorResult(Map<String, Object?> response) {
return result ?? response;
}

double? _doubleFrom(Object? value) {
if (value is num) {
return value.toDouble();
}

final stringValue = value?.toString().trim();
if (stringValue == null || stringValue.isEmpty) {
return null;
}
return double.tryParse(stringValue);
}

class FlutterInspectorException implements Exception {
const FlutterInspectorException(this.message);

Expand Down
33 changes: 32 additions & 1 deletion apps/bridge/lib/widget_tree/widget_tree_snapshot.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class WidgetTreeNode {
required this.id,
required this.label,
required this.children,
this.bounds,
this.sourceLocation,
this.visibleText,
this.semanticInfo,
Expand Down Expand Up @@ -42,23 +43,26 @@ class WidgetTreeNode {
factory WidgetTreeNode.fromFlutterDiagnostics(
Map<String, Object?> diagnosticsNode, {
String? projectRoot,
Map<String, WidgetBounds> boundsById = const {},
}) {
final rawChildren = diagnosticsNode['children'];
final id = diagnosticsNode['valueId']?.toString() ?? '';
final children = rawChildren is List
? rawChildren
.whereType<Map<String, Object?>>()
.map(
(child) => WidgetTreeNode.fromFlutterDiagnostics(
child,
projectRoot: projectRoot,
boundsById: boundsById,
),
)
.toList()
: <WidgetTreeNode>[];
final description = diagnosticsNode['description']?.toString() ?? '';

return WidgetTreeNode(
id: diagnosticsNode['valueId']?.toString() ?? '',
id: id,
label: description,
sourceLocation: _sourceLocationFromDiagnostics(
diagnosticsNode,
Expand All @@ -70,12 +74,14 @@ class WidgetTreeNode {
'semanticLabel',
'semanticDescription',
]),
bounds: boundsById[id],
children: children,
);
}

final String id;
final String label;
final WidgetBounds? bounds;
final String? sourceLocation;
final String? visibleText;
final String? semanticInfo;
Expand All @@ -85,6 +91,7 @@ class WidgetTreeNode {
return {
'id': id,
'label': label,
if (bounds != null) 'bounds': bounds!.toJson(),
if (sourceLocation != null) 'sourceLocation': sourceLocation,
if (visibleText != null) 'visibleText': visibleText,
if (semanticInfo != null) 'semanticInfo': semanticInfo,
Expand All @@ -93,6 +100,30 @@ class WidgetTreeNode {
}
}

/// Visual rectangle for a Flutter widget in device-screen coordinates.
class WidgetBounds {
const WidgetBounds({
required this.x,
required this.y,
required this.width,
required this.height,
});

final double x;
final double y;
final double width;
final double height;

Map<String, Object?> toJson() {
return {
'x': x,
'y': y,
'width': width,
'height': height,
};
}
}

String? _sourceLocationFromDiagnostics(
Map<String, Object?> diagnosticsNode, {
required String? projectRoot,
Expand Down
36 changes: 36 additions & 0 deletions apps/bridge/test/chat/chat_ingress_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,42 @@ void main() {
(unowned as RejectedChatIngressMessage).error, 'invalid_chat_parts');
expect(owned, isA<AcceptedChatIngressMessage>());
});

test('does not restore snapshot ownership in a restarted Bridge Session',
() {
const snapshotPath = '/tmp/ask-ui/session-1/snapshots/comment.png';
existingSnapshotPaths.add(snapshotPath);
session.manageLocalPath('/tmp/ask-ui/session-1');
final originalSessionResult = ingress.parseMessage(
{
'parts': [
selectionCommentPart(snapshotPath),
],
},
session,
);
final restartedSession = BridgeSession(
id: 'session-1',
vmServiceUri: session.vmServiceUri,
projectRoot: session.projectRoot,
deviceId: session.deviceId,
);

final restartedSessionResult = ingress.parseMessage(
{
'parts': [
selectionCommentPart(snapshotPath),
],
},
restartedSession,
);

expect(originalSessionResult, isA<AcceptedChatIngressMessage>());
expect(
(restartedSessionResult as RejectedChatIngressMessage).error,
'invalid_chat_parts',
);
});
});
}

Expand Down
Loading
Loading