Files
plezy/test/utils/semantics_tree_gate_test.dart
T
edde746 7e1f18f93f perf(android): skip the semantics tree when no enabled accessibility service can read it
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.
2026-09-02 22:38:11 +02:00

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);
});
}