diff --git a/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart b/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart index 679bdccd399..d1a34602078 100644 --- a/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart +++ b/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart @@ -12,6 +12,7 @@ import 'package:logging/logging.dart'; import '../../service/service_extensions.dart' as extensions; import '../../service/service_registrations.dart' as registrations; +import '../../service/view_metrics_override_client.dart'; import '../../shared/framework/screen.dart'; import '../../shared/framework/screen_controllers.dart'; import '../../shared/globals.dart'; @@ -46,15 +47,12 @@ class AccessibilityController extends DevToolsScreenController void init() { super.init(); _initServiceExtensionStates(); + _initViewMetricsOverrides(); _initSemanticsTree(); } void _initListeners() { addAutoDisposeListener(brightness, _onBrightnessChanged); - addAutoDisposeListener(textScale, _onTextScaleChanged); - addAutoDisposeListener(boldText, _onBoldTextChanged); - addAutoDisposeListener(screenReader, _onScreenReaderChanged); - addAutoDisposeListener(highContrast, _onHighContrastChanged); addAutoDisposeListener(semanticsRoots, _onSemanticsRootsChanged); } @@ -123,21 +121,78 @@ class AccessibilityController extends DevToolsScreenController ); } - void _onTextScaleChanged() { - // TODO(hannah-hyj): Implement VM service extension call for text scale override. - } + // The metrics of `ext.flutter.viewMetricsOverride` that the overrides pane + // controls. Brightness stays on `ext.flutter.brightnessOverride`. + static const _textScalingMetric = 'textScaling'; + static const _boldTextMetric = 'boldText'; + static const _accessibleNavigationMetric = 'accessibleNavigation'; + static const _highContrastMetric = 'highContrast'; + + /// The client that overrides the text scale, bold text, screen reader and + /// high contrast settings of the app, or null before [init]. + ViewMetricsOverrideClient? _viewMetricsOverrideClient; + + void _initViewMetricsOverrides() { + final client = _viewMetricsOverrideClient = ViewMetricsOverrideClient(); + void updateFromApp() { + final overrides = client.overrides.value; + _overridesAvailable.value = client.available.value; + _textScale.value = _textScaleFactor(overrides[_textScalingMetric]); + _boldText.value = overrides[_boldTextMetric] == true; + _screenReader.value = overrides[_accessibleNavigationMetric] == true; + _highContrast.value = overrides[_highContrastMetric] == true; + } - void _onBoldTextChanged() { - // TODO(hannah-hyj): Implement VM service extension call for bold text override. + updateFromApp(); + addAutoDisposeListener(client.available, updateFromApp); + addAutoDisposeListener(client.overrides, updateFromApp); } - void _onScreenReaderChanged() { - // TODO(hannah-hyj): Implement VM service extension call for screen reader / semantics debugger. - // e.g. using 'ext.flutter.showSemanticsDebugger'. + /// Overrides the text scale factor of the app with [factor], or stops + /// overriding it if [factor] is null. + /// + /// The override scales every font size linearly by [factor]. + Future setTextScale(double? factor) => _setOverride( + _textScalingMetric, + factor == null + ? null + : [ + [1, factor], + ], + ); + + /// Makes the app use bold text, or the bold text setting of the platform + /// again if [enabled] is false. + Future setBoldText(bool enabled) => + _setFlagOverride(_boldTextMetric, enabled); + + /// Makes the app behave as though a screen reader is on, or follow the + /// platform again if [enabled] is false. + Future setScreenReader(bool enabled) => + _setFlagOverride(_accessibleNavigationMetric, enabled); + + /// Makes the app use high contrast, or the high contrast setting of the + /// platform again if [enabled] is false. + Future setHighContrast(bool enabled) => + _setFlagOverride(_highContrastMetric, enabled); + + // Turning an override off stops overriding the flag rather than overriding + // it to false, so that the app goes back to the setting of the platform. + Future _setFlagOverride(String metric, bool enabled) => + _setOverride(metric, enabled ? true : null); + + Future _setOverride(String metric, Object? value) async { + await _viewMetricsOverrideClient?.updateOverrides({metric: value}); } - void _onHighContrastChanged() { - // TODO(hannah-hyj): Implement VM service extension call for high contrast override. + /// Returns the ratio of the first point of [textScaling], which is the text + /// scale factor that the app reports, or null if text scale is not + /// overridden. + static double? _textScaleFactor(Object? textScaling) { + if (textScaling case [[final num unscaled, final num scaled], ...]) { + return scaled / unscaled; + } + return null; } @override @@ -147,10 +202,33 @@ class AccessibilityController extends DevToolsScreenController final brightness = ValueNotifier( BrightnessOverride.system, ); - final textScale = ValueNotifier(1.0); - final boldText = ValueNotifier(false); - final screenReader = ValueNotifier(false); - final highContrast = ValueNotifier(false); + + /// Whether the connected app supports the text scale, bold text, screen + /// reader and high contrast overrides. + /// + /// See [ViewMetricsOverrideClient.available] for the apps that do. + ValueListenable get overridesAvailable => _overridesAvailable; + final _overridesAvailable = ValueNotifier(false); + + /// The text scale factor that the app is overridden to, or null if the app + /// uses the text scale that the platform reports. + ValueListenable get textScale => _textScale; + final _textScale = ValueNotifier(null); + + /// Whether the app is overridden to use bold text. + ValueListenable get boldText => _boldText; + final _boldText = ValueNotifier(false); + + /// Whether the app is overridden to behave as though a screen reader is on. + /// + /// This overrides `AccessibilityFeatures.accessibleNavigation`, which + /// changes how widgets behave. It does not start a screen reader. + ValueListenable get screenReader => _screenReader; + final _screenReader = ValueNotifier(false); + + /// Whether the app is overridden to use high contrast. + ValueListenable get highContrast => _highContrast; + final _highContrast = ValueNotifier(false); final semanticsRoots = ValueNotifier>([]); final selectedSemanticsNode = ValueNotifier(null); @@ -321,11 +399,13 @@ class AccessibilityController extends DevToolsScreenController @override void dispose() { unawaited(_disposeSemanticsOnApp()); + _viewMetricsOverrideClient?.dispose(); brightness.dispose(); - textScale.dispose(); - boldText.dispose(); - screenReader.dispose(); - highContrast.dispose(); + _overridesAvailable.dispose(); + _textScale.dispose(); + _boldText.dispose(); + _screenReader.dispose(); + _highContrast.dispose(); semanticsRoots.dispose(); selectedSemanticsNode.dispose(); semanticsTreeLoading.dispose(); diff --git a/packages/devtools_app/lib/src/screens/accessibility/overrides_pane.dart b/packages/devtools_app/lib/src/screens/accessibility/overrides_pane.dart index 9e2555ac122..bfdaf070cb4 100644 --- a/packages/devtools_app/lib/src/screens/accessibility/overrides_pane.dart +++ b/packages/devtools_app/lib/src/screens/accessibility/overrides_pane.dart @@ -2,7 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +import 'dart:async'; + import 'package:devtools_app_shared/ui.dart'; +import 'package:flutter/foundation.dart'; import 'package:material_ui/material_ui.dart'; import '../../shared/globals.dart'; @@ -38,25 +41,7 @@ class AccessibilityOverridesPane extends StatelessWidget { const SizedBox(height: denseSpacing), _BrightnessOverride(controller: controller), const SizedBox(height: defaultSpacing), - _TextScaleOverride(controller: controller), - const SizedBox(height: defaultSpacing), - _SwitchOverride( - label: 'Bold Text', - description: 'Forces all text in the application to be bold.', - notifier: controller.boldText, - ), - const SizedBox(height: defaultSpacing), - _SwitchOverride( - label: 'Screen Reader Debugger', - description: 'Debug and test screen reader layouts.', - notifier: controller.screenReader, - ), - const SizedBox(height: defaultSpacing), - _SwitchOverride( - label: 'High Contrast', - description: 'Increases the contrast of text and icons.', - notifier: controller.highContrast, - ), + _ViewMetricsOverrides(controller: controller), ], ), ), @@ -128,46 +113,134 @@ class _BrightnessOverride extends StatelessWidget { } } -class _TextScaleOverride extends StatelessWidget { - const _TextScaleOverride({required this.controller}); +/// The controls of `ext.flutter.viewMetricsOverride`, which are disabled with +/// a tooltip while the app does not support it. +class _ViewMetricsOverrides extends StatelessWidget { + const _ViewMetricsOverrides({required this.controller}); final AccessibilityController controller; + static const _unavailableMessage = + 'Unavailable because the connected app is a profile build, or uses a ' + 'Flutter version that does not support these overrides.'; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller.overridesAvailable, + builder: (context, available, _) { + final overrides = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _TextScaleOverride(controller: controller, enabled: available), + const SizedBox(height: defaultSpacing), + _SwitchOverride( + label: 'Bold Text', + description: 'Forces all text in the application to be bold.', + value: controller.boldText, + onChanged: available ? controller.setBoldText : null, + ), + const SizedBox(height: defaultSpacing), + _SwitchOverride( + label: 'Screen Reader Debugger', + description: 'Debug and test screen reader layouts.', + value: controller.screenReader, + onChanged: available ? controller.setScreenReader : null, + ), + const SizedBox(height: defaultSpacing), + _SwitchOverride( + label: 'High Contrast', + description: 'Increases the contrast of text and icons.', + value: controller.highContrast, + onChanged: available ? controller.setHighContrast : null, + ), + ], + ); + if (available) return overrides; + return DevToolsTooltip(message: _unavailableMessage, child: overrides); + }, + ); + } +} + +class _TextScaleOverride extends StatefulWidget { + const _TextScaleOverride({required this.controller, required this.enabled}); + + final AccessibilityController controller; + + /// Whether the text scale can be changed. + final bool enabled; + + @override + State<_TextScaleOverride> createState() => _TextScaleOverrideState(); +} + +class _TextScaleOverrideState extends State<_TextScaleOverride> { static const _minTextScale = 0.5; static const _maxTextScale = 3.0; static const _textScaleDivisions = 25; + /// The text scale that the slider shows while the user moves it, until the + /// app answers the override. + double? _sliderTextScale; + + Future _overrideTextScale(double textScale) async { + await widget.controller.setTextScale(textScale); + if (mounted && _sliderTextScale == textScale) { + setState(() => _sliderTextScale = null); + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); - return ValueListenableBuilder( - valueListenable: controller.textScale, - builder: (context, value, _) { + final enabled = widget.enabled; + return ValueListenableBuilder( + valueListenable: widget.controller.textScale, + builder: (context, appTextScale, _) { + final textScale = _sliderTextScale ?? appTextScale; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const _AccessibilityPanelLabel( - label: 'Text Scale', - description: 'Scale the system font size.', + const Expanded( + child: _AccessibilityPanelLabel( + label: 'Text Scale', + description: 'Scale the system font size.', + ), ), Text( - '${value.toStringAsFixed(2)}x', + // 1.0 is an override too, so the text scale of the platform + // is shown by name rather than as a factor. + textScale == null + ? 'System default' + : '${textScale.toStringAsFixed(2)}x', style: theme.boldTextStyle, ), + const SizedBox(width: denseSpacing), + DevToolsButton.iconOnly( + icon: Icons.restart_alt, + outlined: false, + tooltip: 'Use the system text scale', + onPressed: enabled && appTextScale != null + ? () => unawaited(widget.controller.setTextScale(null)) + : null, + ), ], ), const SizedBox(height: densePadding), Slider( - value: value, + value: (textScale ?? 1.0).clamp(_minTextScale, _maxTextScale), min: _minTextScale, max: _maxTextScale, divisions: _textScaleDivisions, - onChanged: (newValue) { - controller.textScale.value = newValue; - }, + onChanged: enabled + ? (value) => setState(() => _sliderTextScale = value) + : null, + onChangeEnd: enabled + ? (value) => unawaited(_overrideTextScale(value)) + : null, ), ], ); @@ -180,12 +253,18 @@ class _SwitchOverride extends StatelessWidget { const _SwitchOverride({ required this.label, required this.description, - required this.notifier, + required this.value, + required this.onChanged, }); final String label; final String description; - final ValueNotifier notifier; + + /// Whether the app has this override on. + final ValueListenable value; + + /// Called when the user toggles the switch, or null to disable the switch. + final ValueChanged? onChanged; @override Widget build(BuildContext context) { @@ -197,7 +276,11 @@ class _SwitchOverride extends StatelessWidget { description: description, ), ), - NotifierSwitch(notifier: notifier), + ValueListenableBuilder( + valueListenable: value, + builder: (context, isOn, _) => + DevToolsSwitch(value: isOn, onChanged: onChanged), + ), ], ); } diff --git a/packages/devtools_app/lib/src/service/service_registrations.dart b/packages/devtools_app/lib/src/service/service_registrations.dart index 52c049f7983..a2c8596eff7 100644 --- a/packages/devtools_app/lib/src/service/service_registrations.dart +++ b/packages/devtools_app/lib/src/service/service_registrations.dart @@ -70,3 +70,6 @@ const getIssuesAndSemanticsTree = /// Service extension to dispose semantics on the connected app. const disposeSemantics = 'ext.flutter.accessibility.disposeSemantics'; + +/// Service extension to override the view metrics of the connected app. +const viewMetricsOverride = 'ext.flutter.viewMetricsOverride'; diff --git a/packages/devtools_app/lib/src/service/view_metrics_override_client.dart b/packages/devtools_app/lib/src/service/view_metrics_override_client.dart new file mode 100644 index 00000000000..23e92a8485e --- /dev/null +++ b/packages/devtools_app/lib/src/service/view_metrics_override_client.dart @@ -0,0 +1,269 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:devtools_app_shared/service.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; +import 'package:vm_service/vm_service.dart'; + +import '../shared/globals.dart'; +import 'service_registrations.dart' as registrations; + +final _log = Logger('view_metrics_override_client'); + +/// A client for the `ext.flutter.viewMetricsOverride` service extension, which +/// makes a debug Flutter app behave as though the platform reported different +/// view metrics, such as a larger text scale or bold text. +/// +/// The app keeps the overrides in a registry keyed by view ID. Every reply of +/// the extension, and every `Flutter.ServiceExtensionStateChanged` event that +/// it posts, carries the whole registry. This client takes [overrides] from +/// the latest of them, so a call that fails leaves [overrides] as the app last +/// reported it. +/// +/// The extension cannot list view IDs yet, so this client only acts on the +/// implicit view, whose ID is 0. +/// +/// The registry lives in the main isolate of the app, so a hot restart empties +/// it. When the extension is registered again on the new isolate, this client +/// reads the registry and restores the metrics that it set, with the values +/// that they had before the restart. +/// +/// See `FoundationServiceExtensions.viewMetricsOverride` in +/// `package:flutter/foundation.dart` for the protocol. +class ViewMetricsOverrideClient extends DisposableController + with AutoDisposeControllerMixin { + ViewMetricsOverrideClient() { + final serviceManager = serviceConnection.serviceManager; + addAutoDisposeListener( + serviceManager.isolateManager.mainIsolate, + _onMainIsolateChanged, + ); + final service = serviceManager.service; + if (service != null) { + autoDisposeStreamSubscription( + service.onExtensionEvent.listen(_onExtensionEvent), + ); + } + _onMainIsolateChanged(); + } + + /// The ID of the implicit view, as a key of the registry. + static const _implicitViewId = '0'; + + /// Whether the main isolate of the connected app has registered the + /// extension, which only debug builds of recent Flutter versions do. + ValueListenable get available => _available; + final _available = ValueNotifier(false); + + /// The overrides that the app has for the implicit view, as it last reported + /// them, such as `{'boldText': true}`. + /// + /// The map has the JSON format of `DebugViewMetricsOverride` in + /// `package:flutter/foundation.dart`. It is empty until the app first + /// reports its overrides. + ValueListenable> get overrides => _overrides; + final _overrides = ValueNotifier>(const {}); + + /// Whether the current main isolate has reported its overrides. + bool _overridesReported = false; + + /// The metrics that this client has changed, which it sets again after a hot + /// restart if the app still overrides them. + final _ownMetrics = {}; + + /// The metrics to set again once the extension is registered on the new + /// main isolate, with the values that they had on the previous one. + Map _metricsToRestore = const {}; + + /// The number of times that the main isolate has changed, so that the calls + /// meant for an isolate that is gone are dropped, and their replies ignored. + int _isolateGeneration = 0; + + /// The [ServiceExtensionManager.hasServiceExtension] listenable of the + /// extension for the current main isolate. + /// + /// [ServiceExtensionManager] stops updating the listenables that it returned + /// once the main isolate exits, so this is looked up again for every main + /// isolate. + ValueListenable? _hasExtension; + + /// Completes when the last call that this client queued for the current + /// main isolate is done. + /// + /// The calls run one at a time, so that replies apply in the order the calls + /// were made, and every write starts from the reply to the call before it. + Future _lastCall = Future.value(); + + /// Overrides the metrics in [metrics] for the implicit view, and keeps the + /// other [overrides] that the view has. + /// + /// The keys and values of [metrics] have the format of [overrides]. A null + /// value stops overriding the metric, so that the app uses the value that + /// the platform reports. + /// + /// A change that another client makes after this client last heard from the + /// app, and before the app receives this call, is lost. + /// + /// Does nothing while the extension is not [available]. The returned future + /// does not complete if a hot restart interrupts the call. + Future updateOverrides(Map metrics) => _queue(() { + _ownMetrics.addAll(metrics.keys); + return _write(metrics); + }); + + /// Runs [call] once the calls queued before it for the current main isolate + /// are done, unless the main isolate has changed or the extension is not + /// [available] by then. + Future _queue(Future Function() call) { + final isolateGeneration = _isolateGeneration; + return _lastCall = _lastCall.then((_) async { + if (disposed || + isolateGeneration != _isolateGeneration || + !_available.value) { + return; + } + await call(); + }); + } + + Future _read() => _call(const {}); + + Future _write(Map metrics) async { + final isolateGeneration = _isolateGeneration; + // A write that does not start from the overrides of the app could remove + // the overrides that other clients set, so read them first if needed. + if (!_overridesReported) await _read(); + if (!_overridesReported || isolateGeneration != _isolateGeneration) return; + final viewOverrides = {..._overrides.value, ...metrics} + ..removeWhere((_, value) => value == null); + await _call({ + 'viewId': _implicitViewId, + 'overrides': jsonEncode(viewOverrides), + }); + } + + /// Calls the extension with [parameters], and takes [overrides] from the + /// reply. + Future _call(Map parameters) async { + final isolateGeneration = _isolateGeneration; + try { + final response = await serviceConnection.serviceManager + .callServiceExtensionOnMainIsolate( + registrations.viewMetricsOverride, + args: parameters, + ); + if (disposed || isolateGeneration != _isolateGeneration) return; + _takeOverridesFrom(response.json?['overrides']); + } catch (e, st) { + _log.warning( + 'Failed to call ${registrations.viewMetricsOverride}: $e', + e, + st, + ); + } + } + + /// Takes [overrides] from [registry], which is the `overrides` of a reply or + /// the `value` of an event. + void _takeOverridesFrom(Object? registry) { + final viewOverrides = registry is Map + ? registry[_implicitViewId] ?? const {} + : null; + if (viewOverrides is! Map) { + _log.warning( + 'Unexpected overrides from ${registrations.viewMetricsOverride}: ' + '$registry', + ); + return; + } + _overridesReported = true; + // Replies, and events about other views, often carry unchanged overrides + // for this view, so notify only when they differ. + if (!const DeepCollectionEquality().equals( + viewOverrides, + _overrides.value, + )) { + _overrides.value = Map.unmodifiable(viewOverrides); + } + } + + void _onExtensionEvent(Event event) { + if (event.extensionKind != FlutterEvent.serviceExtensionStateChanged) { + return; + } + final data = event.extensionData?.data; + if (data == null || + data['extension'] != registrations.viewMetricsOverride) { + return; + } + // An isolate that a hot restart replaced can still post an event, which + // describes a registry that is gone. + final mainIsolate = + serviceConnection.serviceManager.isolateManager.mainIsolate.value; + if (mainIsolate == null || event.isolate?.id != mainIsolate.id) return; + // Unlike the other extensions, this one posts its state as a JSON object, + // which ServiceExtensionManager would turn into a string. + _takeOverridesFrom(data['value']); + } + + void _onMainIsolateChanged() { + _isolateGeneration++; + // The VM service never answers a call that is in flight when its isolate + // exits, so the calls for the new main isolate do not wait for older ones. + _lastCall = Future.value(); + // A hot restart replaces the main isolate, and the registry with it, so + // remember the values of the metrics that this client set. + final viewOverrides = _overrides.value; + _metricsToRestore = { + ..._metricsToRestore, + for (final metric in _ownMetrics) + if (viewOverrides.containsKey(metric)) metric: viewOverrides[metric], + }; + _overridesReported = false; + + final serviceManager = serviceConnection.serviceManager; + cancelListener(_onAvailabilityChanged); + // ServiceExtensionManager can report the extensions of a new isolate + // before it becomes the main isolate, so wait for a main isolate. + _hasExtension = serviceManager.isolateManager.mainIsolate.value == null + ? null + : serviceManager.serviceExtensionManager.hasServiceExtension( + registrations.viewMetricsOverride, + ); + addAutoDisposeListener(_hasExtension, _onAvailabilityChanged); + _onAvailabilityChanged(); + } + + void _onAvailabilityChanged() { + final available = _hasExtension?.value ?? false; + if (available == _available.value) return; + _available.value = available; + if (!available) return; + unawaited(_queue(_read)); + unawaited(_queue(_restoreMetrics)); + } + + Future _restoreMetrics() async { + final metrics = _metricsToRestore; + if (metrics.isEmpty) return; + final isolateGeneration = _isolateGeneration; + await _write(metrics); + // If the main isolate changed during the write, the new one needs the + // metrics too. + if (isolateGeneration == _isolateGeneration) _metricsToRestore = const {}; + } + + @override + void dispose() { + _available.dispose(); + _overrides.dispose(); + super.dispose(); + } +} diff --git a/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart b/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart index 2575cb09fd3..7a04ef4d44e 100644 --- a/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart +++ b/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart @@ -17,12 +17,16 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; +import '../../test_infra/utils/view_metrics_override_utils.dart'; + void main() { group('AccessibilityController', () { late AccessibilityController controller; + late FakeViewMetricsOverrideExtension fakeExtension; setUp(() { - final fakeServiceConnection = FakeServiceConnectionManager(); + final fakeServiceConnection = ViewMetricsOverrideServiceConnection(); + fakeExtension = fakeServiceConnection.extension; when( fakeServiceConnection.serviceManager.connectedApp!.isFlutterWebAppNow, ).thenReturn(false); @@ -60,7 +64,8 @@ void main() { uninitializedController.brightness.value, BrightnessOverride.system, ); - expect(uninitializedController.textScale.value, 1.0); + expect(uninitializedController.overridesAvailable.value, isFalse); + expect(uninitializedController.textScale.value, isNull); expect(uninitializedController.boldText.value, isFalse); expect(uninitializedController.screenReader.value, isFalse); expect(uninitializedController.highContrast.value, isFalse); @@ -525,6 +530,44 @@ void main() { isTrue, ); }); + + group('view metrics overrides', () { + test('show the overrides of the app once it supports them', () async { + fakeExtension.registry['0'] = { + 'textScaling': [ + [8, 16], + [100, 100], + ], + 'boldText': true, + 'accessibleNavigation': true, + 'highContrast': false, + }; + expect(controller.overridesAvailable.value, isFalse); + + await fakeExtension.register(); + await pumpEventQueue(); + + expect(controller.overridesAvailable.value, isTrue); + // Like the app, the page reports the ratio of the first point. + expect(controller.textScale.value, 2.0); + expect(controller.boldText.value, isTrue); + expect(controller.screenReader.value, isTrue); + expect(controller.highContrast.value, isFalse); + }); + + test('1.0 is a text scale override', () async { + await fakeExtension.register(); + await pumpEventQueue(); + + await controller.setTextScale(1.0); + expect(fakeExtension.registry['0'], { + 'textScaling': [ + [1, 1.0], + ], + }); + expect(controller.textScale.value, 1.0); + }); + }); }); } diff --git a/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart b/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart index e1c14e9ce40..266bf50906a 100644 --- a/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart +++ b/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart @@ -10,15 +10,17 @@ import 'dart:ui' show SemanticsFlag; import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; +import '../../test_infra/utils/view_metrics_override_utils.dart'; + void main() { late AccessibilityScreen screen; late AccessibilityController controller; + late FakeViewMetricsOverrideExtension fakeExtension; const windowSize = Size(1000.0, 1000.0); group('Accessibility Screen', () { @@ -32,7 +34,8 @@ void main() { } setUp(() { - final fakeServiceConnection = FakeServiceConnectionManager(); + final fakeServiceConnection = ViewMetricsOverrideServiceConnection(); + fakeExtension = fakeServiceConnection.extension; when( fakeServiceConnection.serviceManager.connectedApp!.isFlutterWebAppNow, ).thenReturn(false); @@ -111,7 +114,7 @@ void main() { // Text Scale controls expect(find.text('Text Scale'), findsOneWidget); expect(find.text('Scale the system font size.'), findsOneWidget); - expect(find.text('1.00x'), findsOneWidget); + expect(find.text('System default'), findsOneWidget); expect(find.byType(Slider), findsOneWidget); // Switches @@ -134,20 +137,21 @@ void main() { }, ); + Finder findSwitchFor(String label) { + return find.descendant( + of: find.ancestor(of: find.text(label), matching: find.byType(Row)), + matching: find.byType(Switch), + ); + } + testWidgetsWithWindowSize( - 'interacting with override controls updates controller state', + 'interacting with override controls updates the app and the controller', windowSize, (WidgetTester tester) async { await pumpAccessibilityScreen(tester); + await fakeExtension.register(); await tester.pumpAndSettle(); - Finder findSwitchFor(String label) { - return find.descendant( - of: find.ancestor(of: find.text(label), matching: find.byType(Row)), - matching: find.byType(Switch), - ); - } - // 1. Test Brightness Dropdown expect(controller.brightness.value, BrightnessOverride.system); await tester.tap( @@ -184,6 +188,87 @@ void main() { await tester.tap(highContrastSwitch); await tester.pumpAndSettle(); expect(controller.highContrast.value, isTrue); + + // Brightness does not go through ext.flutter.viewMetricsOverride. + expect(fakeExtension.registry, { + '0': { + 'boldText': true, + 'accessibleNavigation': true, + 'highContrast': true, + }, + }); + + // Turning a switch off lets the app use the setting of the platform. + await tester.tap(boldTextSwitch); + await tester.pumpAndSettle(); + expect(controller.boldText.value, isFalse); + expect(fakeExtension.registry['0'], { + 'accessibleNavigation': true, + 'highContrast': true, + }); + }, + ); + + testWidgetsWithWindowSize( + 'disables the overrides and explains why while the app does not support them', + windowSize, + (WidgetTester tester) async { + await pumpAccessibilityScreen(tester); + await tester.pumpAndSettle(); + + final unavailableTooltip = find.byWidgetPredicate( + (widget) => + widget is Tooltip && + (widget.message ?? '').contains('profile build'), + ); + for (final element in find.byType(Switch).evaluate()) { + expect((element.widget as Switch).onChanged, isNull); + } + expect(tester.widget(find.byType(Slider)).onChanged, isNull); + expect(unavailableTooltip, findsOneWidget); + + // The controls are enabled once the app registers the extension. + await fakeExtension.register(); + await tester.pumpAndSettle(); + expect( + tester.widget(findSwitchFor('Bold Text')).onChanged, + isNotNull, + ); + expect(tester.widget(find.byType(Slider)).onChanged, isNotNull); + expect(unavailableTooltip, findsNothing); + }, + ); + + testWidgetsWithWindowSize( + 'text scale shows the override of the app, and the slider and reset change it', + windowSize, + (WidgetTester tester) async { + fakeExtension.registry['0'] = { + 'textScaling': [ + [1.0, 4.0], + ], + }; + await pumpAccessibilityScreen(tester); + await fakeExtension.register(); + await tester.pumpAndSettle(); + + // The slider stays within its range. + expect(find.text('4.00x'), findsOneWidget); + expect(tester.widget(find.byType(Slider)).value, 3.0); + + await tester.drag(find.byType(Slider), const Offset(-1000, 0)); + await tester.pumpAndSettle(); + expect(find.text('0.50x'), findsOneWidget); + expect(fakeExtension.registry['0'], { + 'textScaling': [ + [1, 0.5], + ], + }); + + await tester.tap(find.byIcon(Icons.restart_alt)); + await tester.pumpAndSettle(); + expect(find.text('System default'), findsOneWidget); + expect(fakeExtension.registry, isEmpty); }, ); diff --git a/packages/devtools_app/test/service/view_metrics_override_client_test.dart b/packages/devtools_app/test/service/view_metrics_override_client_test.dart new file mode 100644 index 00000000000..ccde094d442 --- /dev/null +++ b/packages/devtools_app/test/service/view_metrics_override_client_test.dart @@ -0,0 +1,286 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +@TestOn('vm') +library; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/service/view_metrics_override_client.dart'; +import 'package:devtools_app_shared/service.dart' show FlutterEvent; +import 'package:devtools_app_shared/utils.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vm_service/vm_service.dart'; + +import '../test_infra/utils/view_metrics_override_utils.dart'; + +void main() { + group('ViewMetricsOverrideClient', () { + late ViewMetricsOverrideServiceConnection connection; + late FakeViewMetricsOverrideExtension extension; + late ViewMetricsOverrideClient client; + + void emitExtensionEvent(Event event) { + (connection.serviceManager.service as FakeVmServiceWrapper) + .emitExtensionEvent(event); + } + + Event stateChangedEvent({ + required String extensionName, + required Object? value, + IsolateRef? isolate, + }) { + return Event( + kind: EventKind.kExtension, + extensionKind: FlutterEvent.serviceExtensionStateChanged, + extensionData: ExtensionData.parse({ + 'extension': extensionName, + 'value': value, + }), + isolate: + isolate ?? + connection.serviceManager.isolateManager.mainIsolate.value, + timestamp: 0, + ); + } + + setUp(() { + connection = ViewMetricsOverrideServiceConnection(); + extension = connection.extension; + setGlobal(ServiceConnectionManager, connection); + client = ViewMetricsOverrideClient(); + }); + + tearDown(() { + client.dispose(); + }); + + test('is not available until the extension is registered', () async { + expect(client.available.value, isFalse); + + await client.updateOverrides({'boldText': true}); + + expect(extension.calls, isEmpty); + }); + + test('reads the registry once the extension is registered', () async { + extension.registry['0'] = {'platformBrightness': 'dark'}; + extension.registry['1'] = {'boldText': true}; + + await extension.register(); + await pumpEventQueue(); + + expect(client.available.value, isTrue); + expect(extension.calls, [{}]); + expect(client.overrides.value, {'platformBrightness': 'dark'}); + + // A client created once the extension is registered reads it too. + final otherClient = ViewMetricsOverrideClient(); + addTearDown(otherClient.dispose); + await pumpEventQueue(); + expect(otherClient.available.value, isTrue); + expect(otherClient.overrides.value, {'platformBrightness': 'dark'}); + }); + + test('updateOverrides keeps the metrics that other clients set', () async { + extension.registry['0'] = { + 'platformBrightness': 'dark', + 'boldText': true, + }; + await extension.register(); + await pumpEventQueue(); + + await client.updateOverrides({'highContrast': true, 'boldText': null}); + + expect(extension.registry, { + '0': {'platformBrightness': 'dark', 'highContrast': true}, + }); + expect(client.overrides.value, { + 'platformBrightness': 'dark', + 'highContrast': true, + }); + }); + + test('follows the changes that other clients make', () async { + await extension.register(); + await pumpEventQueue(); + var notifications = 0; + client.overrides.addListener(() => notifications++); + + extension.callFromOtherClient({ + 'viewId': '0', + 'overrides': jsonEncode({ + 'textScaling': [ + [1, 2], + ], + }), + }); + await pumpEventQueue(); + expect(client.overrides.value, { + 'textScaling': [ + [1, 2], + ], + }); + expect(notifications, 1); + + // A change to another view does not change the overrides of view 0. + extension.callFromOtherClient({ + 'viewId': '1', + 'overrides': jsonEncode({'boldText': true}), + }); + await pumpEventQueue(); + expect(notifications, 1); + + // A write starts from the latest event, without reading first. + await client.updateOverrides({'boldText': true}); + expect(extension.calls, [ + {}, + {'viewId': '0', 'overrides': anything}, + ]); + expect(extension.registry['0'], { + 'textScaling': [ + [1, 2], + ], + 'boldText': true, + }); + }); + + test('ignores events of other extensions and of other isolates', () async { + extension.registry['0'] = {'boldText': true}; + await extension.register(); + await pumpEventQueue(); + + emitExtensionEvent( + stateChangedEvent( + extensionName: 'ext.flutter.brightnessOverride', + value: {}, + ), + ); + emitExtensionEvent( + stateChangedEvent( + extensionName: viewMetricsOverride, + value: {}, + isolate: IsolateRef(id: 'exited_isolate'), + ), + ); + await pumpEventQueue(); + + expect(client.overrides.value, {'boldText': true}); + }); + + test('failed calls keep the overrides and the restore', () async { + final error = RPCError( + viewMetricsOverride, + RPCErrorKind.kServerError.code, + 'Failed', + ); + await extension.register(); + await pumpEventQueue(); + await client.updateOverrides({'boldText': true}); + + extension.nextCallError = error; + await client.updateOverrides({'highContrast': true}); + expect(extension.registry, { + '0': {'boldText': true}, + }); + expect(client.overrides.value, {'boldText': true}); + + // The first read after the hot restart fails, so the restore reads again. + extension.nextCallError = error; + await extension.hotRestart(); + await pumpEventQueue(); + expect(extension.registry, { + '0': {'boldText': true}, + }); + }); + + test('writes run one at a time, each from the reply before it', () async { + await extension.register(); + await pumpEventQueue(); + + await Future.wait([ + client.updateOverrides({'boldText': true}), + client.updateOverrides({'highContrast': true}), + ]); + + expect(extension.registry['0'], {'boldText': true, 'highContrast': true}); + }); + + test('sets its metrics again after a hot restart', () async { + extension.registry['0'] = {'platformBrightness': 'dark'}; + await extension.register(); + await pumpEventQueue(); + await client.updateOverrides({ + 'boldText': true, + 'textScaling': [ + [1, 2], + ], + }); + await client.updateOverrides({'textScaling': null}); + extension.callFromOtherClient({ + 'viewId': '0', + 'overrides': jsonEncode({ + 'platformBrightness': 'dark', + 'boldText': false, + }), + }); + await pumpEventQueue(); + + extension.calls.clear(); + await extension.hotRestart(); + await pumpEventQueue(); + + expect(extension.calls, [ + {}, + {'viewId': '0', 'overrides': anything}, + ]); + // Only the metrics that this client set and that the app still had are + // set again, with the values that they had. The metrics of other clients + // are theirs to set again. + expect(extension.registry, { + '0': {'boldText': false}, + }); + expect(client.overrides.value, {'boldText': false}); + }); + + test( + 'does not wait for calls that an exited isolate never answers', + () async { + await extension.register(); + await pumpEventQueue(); + await client.updateOverrides({'boldText': true}); + extension.calls.clear(); + await extension.hotRestart(); + // Let the new isolate be read, and the restore be sent. + for (var i = 0; i < 5 && extension.calls.length < 2; i++) { + await Future.delayed(Duration.zero); + } + expect(extension.calls, [ + {}, + {'viewId': '0', 'overrides': '{"boldText":true}'}, + ]); + expect(extension.registry, isEmpty); + + // The app restarts again before it answers the restore, while a write + // waits for the restore in the queue. + unawaited(client.updateOverrides({'highContrast': true})); + extension.calls.clear(); + await extension.hotRestart(); + await pumpEventQueue(); + + // The write is dropped, and the new isolate gets the restore. + expect(extension.calls, [ + {}, + {'viewId': '0', 'overrides': '{"boldText":true}'}, + ]); + expect(extension.registry, { + '0': {'boldText': true}, + }); + }, + ); + }); +} diff --git a/packages/devtools_app/test/test_infra/utils/view_metrics_override_utils.dart b/packages/devtools_app/test/test_infra/utils/view_metrics_override_utils.dart new file mode 100644 index 00000000000..fb3fa06527f --- /dev/null +++ b/packages/devtools_app/test/test_infra/utils/view_metrics_override_utils.dart @@ -0,0 +1,149 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app_shared/service.dart' show FlutterEvent; +import 'package:devtools_test/devtools_test.dart'; +import 'package:vm_service/vm_service.dart'; + +/// A [FakeServiceConnectionManager] whose main isolate serves the +/// `ext.flutter.viewMetricsOverride` service extension through [extension]. +class ViewMetricsOverrideServiceConnection + extends FakeServiceConnectionManager { + @override + FakeServiceManager get serviceManager => _serviceManager; + final _serviceManager = _ViewMetricsOverrideServiceManager(); + + /// The fake extension that [serviceManager] calls. + FakeViewMetricsOverrideExtension get extension => _serviceManager.extension; +} + +// ignore: subtype_of_sealed_class, fake for testing. +class _ViewMetricsOverrideServiceManager extends FakeServiceManager { + late final extension = FakeViewMetricsOverrideExtension._(this); + + @override + Future callServiceExtensionOnMainIsolate( + String method, { + Map? args, + }) async { + if (method != viewMetricsOverride) { + return super.callServiceExtensionOnMainIsolate(method, args: args); + } + final parameters = Map.from(args ?? const {}); + extension.calls.add(parameters); + final isolate = isolateManager.mainIsolate.value; + // Like the VM service, reply after the caller has moved on, and never + // reply to a call whose isolate exits first. + await Future.delayed(Duration.zero); + if (isolateManager.mainIsolate.value != isolate) { + return Completer().future; + } + final error = extension.nextCallError; + extension.nextCallError = null; + if (error != null) throw error; + return extension._handle(parameters); + } +} + +/// A fake of the `ext.flutter.viewMetricsOverride` service extension of a +/// debug Flutter app. +/// +/// Like the app, it replies with the registry, and posts it in a +/// `Flutter.ServiceExtensionStateChanged` event when a call changes it. It +/// does not validate the parameters. +class FakeViewMetricsOverrideExtension { + FakeViewMetricsOverrideExtension._(this._serviceManager); + + final FakeServiceManager _serviceManager; + + /// The registry of the app, keyed by view ID. + final registry = >{}; + + /// The parameters of the calls that DevTools made, in order. + final calls = >[]; + + /// An error that the next call from DevTools throws instead of being + /// handled, such as an [RPCError]. + Object? nextCallError; + + int _restarts = 0; + + /// Registers the extension, as the binding of a debug Flutter app does, and + /// sends the first frame. + Future register() async { + final extensionManager = _serviceManager.serviceExtensionManager; + await extensionManager.fakeAddServiceExtension(viewMetricsOverride); + await extensionManager.fakeFrame(); + } + + /// Calls the extension as another client of the app would, such as an MCP + /// server. + void callFromOtherClient(Map parameters) { + _handle(parameters); + } + + /// Hot restarts the app, which replaces the main isolate and empties the + /// registry. + /// + /// As a real app can, the new isolate registers the extension before it + /// becomes the main isolate. + Future hotRestart() async { + final isolateManager = _serviceManager.isolateManager as FakeIsolateManager; + // ServiceExtensionManager starts listening to the main isolate when the VM + // service opens, so it reacts to the old isolate exiting before others. + _serviceManager.serviceExtensionManager.fakeMainIsolateClosed(); + isolateManager.setMainIsolate(null); + registry.clear(); + await register(); + _restarts++; + isolateManager.setMainIsolate( + IsolateRef(id: 'restarted_isolate_$_restarts', name: 'main'), + ); + } + + Response _handle(Map parameters) { + final viewId = parameters['viewId']; + final overrides = parameters['overrides']; + if (viewId != null && overrides != null) { + final previous = registry[viewId]; + final next = jsonDecode(overrides) as Map; + if (next.isEmpty) { + registry.remove(viewId); + } else { + registry[viewId] = next; + } + if (!const DeepCollectionEquality().equals(previous, registry[viewId])) { + _postStateChangedEvent(); + } + } + return Response.parse({ + 'type': '_extensionType', + 'method': viewMetricsOverride, + 'overrides': _encodedRegistry, + })!; + } + + /// The registry as the app serializes it into a reply or an event. + Object? get _encodedRegistry => jsonDecode(jsonEncode(registry)); + + void _postStateChangedEvent() { + (_serviceManager.service as FakeVmServiceWrapper).emitExtensionEvent( + Event( + kind: EventKind.kExtension, + extensionKind: FlutterEvent.serviceExtensionStateChanged, + extensionData: ExtensionData.parse({ + 'extension': viewMetricsOverride, + 'value': _encodedRegistry, + }), + isolate: _serviceManager.isolateManager.mainIsolate.value, + timestamp: 0, + ), + ); + } +} diff --git a/packages/devtools_test/lib/src/mocks/fake_isolate_manager.dart b/packages/devtools_test/lib/src/mocks/fake_isolate_manager.dart index 5e9823fdd07..09c5688a78a 100644 --- a/packages/devtools_test/lib/src/mocks/fake_isolate_manager.dart +++ b/packages/devtools_test/lib/src/mocks/fake_isolate_manager.dart @@ -46,6 +46,12 @@ base class FakeIsolateManager extends Fake with TestIsolateManager { _isolateCreatedController.add(isolate); } + /// Changes the main isolate, as [IsolateManager] does during a hot restart. + @visibleForTesting + void setMainIsolate(IsolateRef? isolate) { + _mainIsolate.value = isolate; + } + @override ValueNotifier> get isolates { return _isolates ??= ValueNotifier([?_selectedIsolate.value]); diff --git a/packages/devtools_test/lib/src/mocks/fake_service_extension_manager.dart b/packages/devtools_test/lib/src/mocks/fake_service_extension_manager.dart index fc47c3adacd..b518859f91f 100644 --- a/packages/devtools_test/lib/src/mocks/fake_service_extension_manager.dart +++ b/packages/devtools_test/lib/src/mocks/fake_service_extension_manager.dart @@ -194,6 +194,16 @@ base class FakeServiceExtensionManager extends Fake } } + /// Hook for tests to simulate the main isolate exiting, as it does during a + /// hot restart. + /// + /// Like [ServiceExtensionManager], this also stops updating the listenables + /// that [hasServiceExtension] returned. + void fakeMainIsolateClosed() { + vmServiceClosed(); + _serviceExtensionAvailable.clear(); + } + /// Sets the state for a service extension and makes the call to the VMService. @override Future setServiceExtensionState( diff --git a/packages/devtools_test/lib/src/mocks/fake_vm_service_wrapper.dart b/packages/devtools_test/lib/src/mocks/fake_vm_service_wrapper.dart index c687bb6d4cb..726c2a40361 100644 --- a/packages/devtools_test/lib/src/mocks/fake_vm_service_wrapper.dart +++ b/packages/devtools_test/lib/src/mocks/fake_vm_service_wrapper.dart @@ -85,6 +85,7 @@ class FakeVmServiceWrapper extends Fake implements VmServiceWrapper { final ClassList? _classList; late final Map? _reverseResolvedUriMap; final _gcEventStream = StreamController.broadcast(); + final _extensionEventStream = StreamController.broadcast(); final _flags = >{ 'flags': [ @@ -533,7 +534,12 @@ class FakeVmServiceWrapper extends Fake implements VmServiceWrapper { Stream get onLoggingEventWithHistorySafe => const Stream.empty(); @override - Stream get onExtensionEvent => const Stream.empty(); + Stream get onExtensionEvent => _extensionEventStream.stream; + + /// Posts [event] to [onExtensionEvent]. + void emitExtensionEvent(Event event) { + _extensionEventStream.sink.add(event); + } @override Stream get onExtensionEventWithHistorySafe => const Stream.empty();