Skip to content

Commit 7d8b801

Browse files
committed
Improve Midscene a11y fallback handling
1 parent 6e72e49 commit 7d8b801

3 files changed

Lines changed: 120 additions & 27 deletions

File tree

android_world/agents/midscene.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44

55
from android_world.agents import base_agent
6+
from android_world.env import adb_utils
67
from android_world.env import interface
78
from android_world.env import representation_utils
89

@@ -160,21 +161,32 @@ def _write_response(self, status: int, content_type: str, body: str):
160161
self.end_headers()
161162
self.wfile.write(body.encode('utf-8'))
162163

164+
def _page_xml_from_adb_dump(self) -> str:
165+
try:
166+
page_xml = adb_utils.uiautomator_dump(agent_ref.env.controller)
167+
if page_xml:
168+
return page_xml
169+
except Exception as e:
170+
agent_ref._formatted_console(
171+
'DOM provider adb dump fallback failed: ' + str(e)
172+
)
173+
return ''
174+
163175
def _page_xml_from_state(self, state) -> str:
164176
if state.forest is None:
165177
agent_ref._formatted_console(
166-
'DOM provider got empty AccessibilityForwarder forest; no page XML returned'
178+
'DOM provider got empty AccessibilityForwarder forest; falling back to adb dump'
167179
)
168-
return ''
180+
return self._page_xml_from_adb_dump()
169181

170182
page_xml = representation_utils.forest_to_raw_xml(state.forest)
171183
if page_xml:
172184
return page_xml
173185

174186
agent_ref._formatted_console(
175-
'DOM provider got empty AccessibilityForwarder XML; no page XML returned'
187+
'DOM provider got empty AccessibilityForwarder XML; falling back to adb dump'
176188
)
177-
return ''
189+
return self._page_xml_from_adb_dump()
178190

179191
def _get_state_with_retry(self):
180192
state = agent_ref.env.get_state()

android_world/env/android_world_controller.py

Lines changed: 74 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@
4242
# Throttle check_airplane_mode: at most once per interval to reduce ADB load
4343
_last_airplane_check: dict[int, float] = {}
4444
_AIRPLANE_CHECK_INTERVAL = 30.0
45+
_A11Y_FORWARDER_SERVICE = (
46+
'com.google.androidenv.accessibilityforwarder/'
47+
'com.google.androidenv.accessibilityforwarder.AccessibilityForwarder'
48+
)
49+
_A11Y_FORWARDER_FLAGS_RECEIVER = (
50+
'com.google.androidenv.accessibilityforwarder/'
51+
'com.google.androidenv.accessibilityforwarder.FlagsBroadcastReceiver'
52+
)
4553

4654

4755
def _has_wrapper(
@@ -109,7 +117,7 @@ def get_a11y_tree(
109117
try:
110118
forest = env.accumulate_new_extras()['accessibility_tree'][-1] # pytype:disable=attribute-error
111119
return forest
112-
except KeyError:
120+
except (KeyError, IndexError):
113121
logging.warning('Could not get a11y tree, retrying.')
114122
time.sleep(sleep_duration)
115123

@@ -454,18 +462,34 @@ def _restart_a11y_forwarder(self) -> bool:
454462
server_port = str(self._adb_server_port)
455463
device_args = ['-s', self._device_name] if self._device_name else []
456464

457-
# Step 1: Re-enable the accessibility service
458-
cmd = [adb_path, '-P', server_port] + device_args + [
459-
'shell', 'settings', 'put', 'secure',
460-
'enabled_accessibility_services',
461-
'com.google.androidenv.accessibilityforwarder/'
462-
'com.google.androidenv.accessibilityforwarder.AccessibilityForwarder',
463-
]
464-
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
465-
if result.returncode != 0:
466-
logging.warning('Failed to re-enable a11y service: %s', result.stderr)
465+
if self._is_remote and not self.ensure_adb_connection():
467466
return False
468467

468+
def run_shell_command(args: list[str]) -> subprocess.CompletedProcess:
469+
cmd = [adb_path, '-P', server_port] + device_args + ['shell'] + args
470+
return subprocess.run(cmd, capture_output=True, text=True, timeout=30)
471+
472+
# Step 1: Re-enable Android accessibility and the forwarder service.
473+
# Some failures leave the service listed but global accessibility off.
474+
for shell_args in (
475+
['settings', 'put', 'secure', 'accessibility_enabled', '1'],
476+
[
477+
'settings',
478+
'put',
479+
'secure',
480+
'enabled_accessibility_services',
481+
_A11Y_FORWARDER_SERVICE,
482+
],
483+
):
484+
result = run_shell_command(shell_args)
485+
if result.returncode != 0:
486+
logging.warning(
487+
'Failed to update a11y setting %s: %s',
488+
' '.join(shell_args),
489+
result.stderr,
490+
)
491+
return False
492+
469493
logging.info('Re-enabled AccessibilityForwarder service')
470494
time.sleep(2.0) # Give the service time to start
471495

@@ -480,8 +504,7 @@ def _restart_a11y_forwarder(self) -> bool:
480504
'shell', 'am', 'broadcast',
481505
'-a', 'accessibility_forwarder.intent.action.SET_GRPC',
482506
'--ei', 'port', str(self._a11y_port),
483-
'-n', 'com.google.androidenv.accessibilityforwarder/'
484-
'com.google.androidenv.accessibilityforwarder.FlagsBroadcastReceiver',
507+
'-n', _A11Y_FORWARDER_FLAGS_RECEIVER,
485508
]
486509
subprocess.run(cmd, capture_output=True, text=True, timeout=30)
487510

@@ -501,6 +524,8 @@ def get_a11y_forest(
501524
2. Restart AccessibilityForwarder service (handles uiautomator disruption)
502525
3. Full environment refresh (handles ADB disconnection / deep failures)
503526
"""
527+
self.ensure_adb_connection()
528+
504529
try:
505530
return self._get_a11y_forest()
506531
except RuntimeError:
@@ -535,25 +560,51 @@ def get_ui_elements(self) -> list[representation_utils.UIElement]:
535560
self.ensure_adb_connection()
536561

537562
if self._a11y_method == A11yMethod.A11Y_FORWARDER_APP:
538-
return representation_utils.forest_to_ui_elements(
539-
self.get_a11y_forest(),
540-
exclude_invisible_elements=True,
541-
)
563+
try:
564+
return representation_utils.forest_to_ui_elements(
565+
self.get_a11y_forest(),
566+
exclude_invisible_elements=True,
567+
)
568+
except RuntimeError as e:
569+
logging.warning(
570+
'A11y tree unavailable after recovery; falling back to '
571+
'uiautomator UI elements: %s',
572+
e,
573+
)
574+
return self._get_uiautomator_ui_elements()
542575
elif self._a11y_method == A11yMethod.UIAUTOMATOR:
576+
return self._get_uiautomator_ui_elements()
577+
else:
578+
return []
579+
580+
def _get_uiautomator_ui_elements(self) -> list[representation_utils.UIElement]:
581+
"""Returns UI elements from uiautomator, or an empty list if it fails."""
582+
try:
543583
return representation_utils.xml_dump_to_ui_elements(
544584
adb_utils.uiautomator_dump(self._env)
545585
)
546-
else:
586+
except Exception as e:
587+
logging.warning('Failed to get UI elements via uiautomator: %s', e)
547588
return []
548589

549590
def _process_timestep(self, timestep: dm_env.TimeStep) -> dm_env.TimeStep:
550591
"""Adds a11y tree info to the observation."""
551592
if self._a11y_method == A11yMethod.A11Y_FORWARDER_APP:
552-
forest = self.get_a11y_forest()
553-
ui_elements = representation_utils.forest_to_ui_elements(
554-
forest,
555-
exclude_invisible_elements=True,
556-
)
593+
try:
594+
forest = self.get_a11y_forest()
595+
except RuntimeError as e:
596+
logging.warning(
597+
'A11y tree unavailable after recovery; falling back to '
598+
'uiautomator UI elements: %s',
599+
e,
600+
)
601+
forest = None
602+
ui_elements = self._get_uiautomator_ui_elements()
603+
else:
604+
ui_elements = representation_utils.forest_to_ui_elements(
605+
forest,
606+
exclude_invisible_elements=True,
607+
)
557608
else:
558609
forest = None
559610
ui_elements = self.get_ui_elements()

android_world/env/android_world_controller_test.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,36 @@ def test_process_timestep(
130130
exclude_invisible_elements=True,
131131
)
132132

133+
@mock.patch.object(adb_utils, 'check_airplane_mode')
134+
@mock.patch.object(android_world_controller, 'get_controller')
135+
@mock.patch.object(android_world_controller, '_has_wrapper')
136+
@mock.patch.object(representation_utils, 'forest_to_ui_elements')
137+
def test_process_timestep_continues_without_a11y_tree(
138+
self,
139+
mock_forest_to_ui,
140+
mock_has_wrapper,
141+
mock_get_controller,
142+
mock_check_airplane_mode,
143+
):
144+
del mock_has_wrapper, mock_get_controller, mock_check_airplane_mode
145+
mock_base_env = mock.Mock(spec=env_interface.AndroidEnvInterface)
146+
env = android_world_controller.AndroidWorldController(mock_base_env)
147+
timestep = dm_env.TimeStep(
148+
observation={}, reward=None, discount=None, step_type=None
149+
)
150+
151+
with mock.patch.object(
152+
env, 'get_a11y_forest', side_effect=RuntimeError('a11y unavailable')
153+
), mock.patch.object(
154+
env, '_get_uiautomator_ui_elements', return_value=['fallback']
155+
) as mock_uiautomator_fallback:
156+
processed_timestep = env._process_timestep(timestep)
157+
158+
self.assertIsNone(processed_timestep.observation['forest'])
159+
self.assertEqual(processed_timestep.observation['ui_elements'], ['fallback'])
160+
mock_uiautomator_fallback.assert_called_once()
161+
mock_forest_to_ui.assert_not_called()
162+
133163
@mock.patch.object(adb_utils, 'check_airplane_mode')
134164
@mock.patch.object(android_world_controller, 'get_controller')
135165
@mock.patch.object(android_world_controller, '_has_wrapper')

0 commit comments

Comments
 (0)