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 887ddf5b9fb..1fd71a307f6 100644 --- a/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart +++ b/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart @@ -2,21 +2,31 @@ // 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/service.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; +import '../../service/service_extensions.dart' as extensions; import '../../shared/framework/screen.dart'; import '../../shared/framework/screen_controllers.dart'; +import '../../shared/globals.dart'; /// Modes for brightness override in the accessibility controls. enum BrightnessOverride { - system('System Default'), - light('Light Mode'), - dark('Dark Mode'); + system('System Default', 'system'), + light('Light Mode', 'Brightness.light'), + dark('Dark Mode', 'Brightness.dark'); - const BrightnessOverride(this.display); + const BrightnessOverride(this.display, this.value); + /// The user-facing display label for this override option. final String display; + + /// The raw value associated with this override option sent to or received + /// from the VM service extension. + final String value; } /// Controller for the Accessibility screen. @@ -26,6 +36,12 @@ class AccessibilityController extends DevToolsScreenController _initListeners(); } + @override + void init() { + super.init(); + _initServiceExtensionStates(); + } + void _initListeners() { addAutoDisposeListener(brightness, _onBrightnessChanged); addAutoDisposeListener(textScale, _onTextScaleChanged); @@ -34,9 +50,34 @@ class AccessibilityController extends DevToolsScreenController addAutoDisposeListener(highContrast, _onHighContrastChanged); } + void _initServiceExtensionStates() { + final state = serviceConnection.serviceManager.serviceExtensionManager + .getServiceExtensionState(extensions.brightnessMode.extension); + + void updateFromDeviceState(ServiceExtensionState state) { + final newBrightness = !state.enabled || state.value == null + ? BrightnessOverride.system + : BrightnessOverride.values.firstWhere( + (b) => b.value == state.value, + orElse: () => BrightnessOverride.system, + ); + brightness.value = newBrightness; + } + + updateFromDeviceState(state.value); + addAutoDisposeListener(state, () => updateFromDeviceState(state.value)); + } + void _onBrightnessChanged() { - // TODO(hannah-hyj): Implement VM service extension call for brightness override. - // e.g. using 'ext.flutter.brightnessOverride'. + final value = brightness.value; + unawaited( + serviceConnection.serviceManager.serviceExtensionManager + .setServiceExtensionState( + extensions.brightnessMode.extension, + enabled: value != BrightnessOverride.system, + value: value.value, + ), + ); } void _onTextScaleChanged() { diff --git a/packages/devtools_app/lib/src/service/service_extensions.dart b/packages/devtools_app/lib/src/service/service_extensions.dart index 7e20efaeb01..b2bf785eb94 100644 --- a/packages/devtools_app/lib/src/service/service_extensions.dart +++ b/packages/devtools_app/lib/src/service/service_extensions.dart @@ -379,6 +379,16 @@ final togglePlatformMode = ServiceExtensionDescription.from( tooltip: 'Override Target Platform', ); +/// Service extension description for overriding brightness in accessibility controls. +final brightnessMode = ServiceExtensionDescription.from( + extensions.brightnessMode, + title: 'Override Brightness', + iconData: Icons.brightness_6, + gaScreenName: gac.accessibility, + gaItem: gac.brightnessOverride, + tooltip: 'Override Brightness', +); + final disableClipLayers = ToggleableServiceExtensionDescription.from( extensions.disableClipLayers, title: 'Render Clip layers', diff --git a/packages/devtools_app/lib/src/shared/analytics/constants.dart b/packages/devtools_app/lib/src/shared/analytics/constants.dart index 3d293268840..c56654fc5c2 100644 --- a/packages/devtools_app/lib/src/shared/analytics/constants.dart +++ b/packages/devtools_app/lib/src/shared/analytics/constants.dart @@ -44,6 +44,7 @@ final vmTools = ScreenMetaData.vmTools.id; const console = 'console'; final simple = ScreenMetaData.simple.id; final deeplink = ScreenMetaData.deepLinks.id; +final accessibility = ScreenMetaData.accessibility.id; // GA events not associated with a any screen e.g., hotReload, hotRestart, etc const devToolsMain = 'main'; @@ -86,6 +87,7 @@ const repaintRainbow = 'repaintRainbow'; const repaintRainbowDocs = 'repaintRainbowDocs'; const debugBanner = 'debugBanner'; const togglePlatform = 'togglePlatform'; +const brightnessOverride = 'brightnessOverride'; const highlightOversizedImages = 'highlightOversizedImages'; const highlightOversizedImagesDocs = 'highlightOversizedImagesDocs'; const selectWidgetMode = 'selectWidgetMode'; diff --git a/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart b/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart new file mode 100644 index 00000000000..1363f9d330c --- /dev/null +++ b/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart @@ -0,0 +1,118 @@ +// 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 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +void main() { + group('AccessibilityController', () { + late AccessibilityController controller; + + setUp(() { + final fakeServiceConnection = FakeServiceConnectionManager(); + when( + fakeServiceConnection.serviceManager.connectedApp!.isFlutterWebAppNow, + ).thenReturn(false); + when( + fakeServiceConnection.serviceManager.connectedApp!.isProfileBuildNow, + ).thenReturn(false); + + setGlobal(NotificationService, NotificationService()); + setGlobal( + DevToolsEnvironmentParameters, + ExternalDevToolsEnvironmentParameters(), + ); + setGlobal(PreferencesController, PreferencesController()); + setGlobal(ServiceConnectionManager, fakeServiceConnection); + + controller = AccessibilityController()..init(); + }); + + test('initial state', () { + expect(controller.brightness.value, BrightnessOverride.system); + }); + + test( + 'service extension state change updates controller brightness state', + () { + final fakeServiceExtensionManager = + serviceConnection.serviceManager.serviceExtensionManager + as FakeServiceExtensionManager; + + expect(controller.brightness.value, BrightnessOverride.system); + + // Simulate service extension state change from device to dark mode + fakeServiceExtensionManager.fakeServiceExtensionStateChanged( + brightnessMode.extension, + 'Brightness.dark', + ); + expect(controller.brightness.value, BrightnessOverride.dark); + + // Simulate service extension state change from device to light mode + fakeServiceExtensionManager.fakeServiceExtensionStateChanged( + brightnessMode.extension, + 'Brightness.light', + ); + expect(controller.brightness.value, BrightnessOverride.light); + + // Simulate service extension state change from device to system + fakeServiceExtensionManager.fakeServiceExtensionStateChanged( + brightnessMode.extension, + 'system', + ); + expect(controller.brightness.value, BrightnessOverride.system); + }, + ); + + test( + 'setting controller brightness updates service extension state', + () async { + final fakeServiceExtensionManager = + serviceConnection.serviceManager.serviceExtensionManager + as FakeServiceExtensionManager; + + // Initial state + expect(controller.brightness.value, BrightnessOverride.system); + + // Set to dark mode + controller.brightness.value = BrightnessOverride.dark; + + // Wait for async operations to complete + await Future.delayed(Duration.zero); + + final darkState = fakeServiceExtensionManager + .getServiceExtensionState(brightnessMode.extension) + .value; + expect(darkState.value, equals('Brightness.dark')); + expect(darkState.enabled, isTrue); + + // Set to light mode + controller.brightness.value = BrightnessOverride.light; + await Future.delayed(Duration.zero); + + final lightState = fakeServiceExtensionManager + .getServiceExtensionState(brightnessMode.extension) + .value; + expect(lightState.value, equals('Brightness.light')); + expect(lightState.enabled, isTrue); + + // Set to system + controller.brightness.value = BrightnessOverride.system; + await Future.delayed(Duration.zero); + + final systemState = fakeServiceExtensionManager + .getServiceExtensionState(brightnessMode.extension) + .value; + expect(systemState.value, equals('system')); + expect(systemState.enabled, isFalse); + }, + ); + }); +} diff --git a/packages/devtools_app_shared/lib/src/service/service_extensions.dart b/packages/devtools_app_shared/lib/src/service/service_extensions.dart index cada4b02b97..608c7c8090d 100644 --- a/packages/devtools_app_shared/lib/src/service/service_extensions.dart +++ b/packages/devtools_app_shared/lib/src/service/service_extensions.dart @@ -201,6 +201,13 @@ final togglePlatformMode = ServiceExtension( values: ['iOS', 'android', 'fuchsia', 'macOS', 'linux'], ); +/// Override the platform brightness (light mode, dark mode, or system default). +final brightnessMode = ServiceExtension( + extension: + '$flutterExtensionPrefix${FoundationServiceExtensions.brightnessOverride.name}', + values: ['system', 'Brightness.light', 'Brightness.dark'], +); + /// Toggle whether interacting with the device selects widgets or triggers /// normal interactions. final toggleSelectWidgetMode = ToggleableServiceExtension( @@ -259,6 +266,7 @@ final _extensionDescriptions = >[ toggleSelectWidgetMode, countWidgetBuilds, profilePlatformChannels, + brightnessMode, ]; /// Service extensions that are not safe to call unless a frame has already @@ -281,6 +289,7 @@ final _unsafeBeforeFirstFrameFlutterExtensions = Set.of( enableOnDeviceInspector, togglePlatformMode, slowAnimations, + brightnessMode, ].map((extension) => extension.extension), ); diff --git a/packages/devtools_app_shared/test/service/service_extensions_test.dart b/packages/devtools_app_shared/test/service/service_extensions_test.dart new file mode 100644 index 00000000000..2ee39d2ef02 --- /dev/null +++ b/packages/devtools_app_shared/test/service/service_extensions_test.dart @@ -0,0 +1,32 @@ +// 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 'package:devtools_app_shared/service_extensions.dart'; +import 'package:test/test.dart'; + +void main() { + group('ServiceExtensions', () { + test('brightnessMode is properly configured', () { + expect( + brightnessMode.extension, + equals('ext.flutter.brightnessOverride'), + ); + expect( + brightnessMode.values, + equals(['system', 'Brightness.light', 'Brightness.dark']), + ); + expect( + serviceExtensionsAllowlist[brightnessMode.extension], + equals(brightnessMode), + ); + }); + + test('brightnessMode is in unsafe before first frame set', () { + expect( + isUnsafeBeforeFirstFlutterFrame('ext.flutter.brightnessOverride'), + isTrue, + ); + }); + }); +}