Android enables Flutter semantics for any bound accessibility service, so a TV running the Projectivy Launcher (whose service only wants foreground-app events) paid for a full semantics tree on every frame that touched a node: on an Android 14 box, 2 ms per frame, ~20 ms on each spotlight swap, and a third of the navigation-time GC churn. The Android side now reports whether any enabled service can consume the tree (touch exploration, an accessibility tool, or spoken/braille/audible/ visual feedback; an empty list means UiAutomation, which reads it too), and AssistiveTechnologyService closes a SemanticsTreeGate on the app binding when none can. The gate sits under SemanticsBinding.semanticsEnabled, so the pipeline owner drops the semantics owner exactly as if the platform had turned accessibility off; explicit ensureSemantics clients (the debug handle for Maestro) always win. Re-evaluated on platform toggles, service-list changes (API 33+) and resume. Cold navigation on the box with Projectivy's service bound, 30 D-pad steps over three hubs: SEMANTICS phase 700-770 ms -> 0, UI frame max 31-37 ms -> 25 ms, GC 3.0 s -> 2.2-2.6 s.
98 lines
3.2 KiB
Dart
98 lines
3.2 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/semantics.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:plezy/utils/semantics_tree_gate.dart';
|
|
|
|
class _GateTestBinding extends AutomatedTestWidgetsFlutterBinding with SemanticsTreeGate {}
|
|
|
|
void main() {
|
|
final binding = _GateTestBinding();
|
|
|
|
SemanticsOwner? owner() => binding.rootPipelineOwner.semanticsOwner;
|
|
|
|
// The tester's own semantics handle is an explicit client, which the gate
|
|
// deliberately never suppresses, so every case runs without it and drives
|
|
// the platform request directly.
|
|
void platformSemantics(bool enabled) => binding.platformDispatcher.semanticsEnabledTestValue = enabled;
|
|
|
|
setUp(() => binding.semanticsTreeWanted = true);
|
|
|
|
testWidgets('closing the gate tears the semantics owner down and reopening restores it', semanticsEnabled: false, (
|
|
tester,
|
|
) async {
|
|
await tester.pumpWidget(const Directionality(textDirection: TextDirection.ltr, child: Text('hello')));
|
|
expect(owner(), isNull);
|
|
|
|
platformSemantics(true);
|
|
await tester.pump();
|
|
expect(owner(), isNotNull);
|
|
expect(binding.semanticsEnabled, isTrue);
|
|
expect(binding.platformSemanticsRequested, isTrue);
|
|
|
|
binding.semanticsTreeWanted = false;
|
|
await tester.pump();
|
|
expect(owner(), isNull);
|
|
expect(binding.semanticsEnabled, isFalse);
|
|
expect(binding.platformSemanticsRequested, isTrue, reason: 'the platform request is untouched');
|
|
|
|
binding.semanticsTreeWanted = true;
|
|
await tester.pump();
|
|
expect(owner(), isNotNull);
|
|
expect(find.bySemanticsLabel('hello'), findsOneWidget);
|
|
|
|
platformSemantics(false);
|
|
await tester.pump();
|
|
expect(owner(), isNull);
|
|
});
|
|
|
|
testWidgets('an explicit ensureSemantics client reopens a closed gate', semanticsEnabled: false, (tester) async {
|
|
await tester.pumpWidget(const Directionality(textDirection: TextDirection.ltr, child: Text('hello')));
|
|
platformSemantics(true);
|
|
binding.semanticsTreeWanted = false;
|
|
await tester.pump();
|
|
expect(owner(), isNull);
|
|
|
|
final handle = binding.ensureSemantics();
|
|
await tester.pump();
|
|
expect(owner(), isNotNull);
|
|
expect(binding.semanticsEnabled, isTrue);
|
|
|
|
handle.dispose();
|
|
// Disposal is invisible to the gate; the next evaluation applies it.
|
|
binding.semanticsTreeWanted = true;
|
|
binding.semanticsTreeWanted = false;
|
|
await tester.pump();
|
|
expect(owner(), isNull);
|
|
|
|
platformSemantics(false);
|
|
await tester.pump();
|
|
});
|
|
|
|
testWidgets('gate flips notify semantics-enabled listeners but not platform listeners', semanticsEnabled: false, (
|
|
tester,
|
|
) async {
|
|
var enabledCalls = 0;
|
|
var platformCalls = 0;
|
|
void onEnabled() => enabledCalls++;
|
|
void onPlatform() => platformCalls++;
|
|
binding.addSemanticsEnabledListener(onEnabled);
|
|
binding.addPlatformSemanticsListener(onPlatform);
|
|
addTearDown(() {
|
|
binding.removeSemanticsEnabledListener(onEnabled);
|
|
binding.removePlatformSemanticsListener(onPlatform);
|
|
});
|
|
|
|
binding.semanticsTreeWanted = false;
|
|
expect(enabledCalls, 1);
|
|
expect(platformCalls, 0);
|
|
|
|
platformSemantics(true);
|
|
expect(enabledCalls, 2);
|
|
expect(platformCalls, 1);
|
|
|
|
platformSemantics(false);
|
|
expect(enabledCalls, 3);
|
|
expect(platformCalls, 2);
|
|
});
|
|
}
|