diff --git a/py-scripts/real_application_tests/zoom_automation/android_zoom.py b/py-scripts/real_application_tests/zoom_automation/android_zoom.py index 7f2f17ca1..6f725b64f 100644 --- a/py-scripts/real_application_tests/zoom_automation/android_zoom.py +++ b/py-scripts/real_application_tests/zoom_automation/android_zoom.py @@ -5,6 +5,7 @@ import logging import os import re +import subprocess import sys import time from datetime import datetime, timedelta @@ -143,6 +144,11 @@ def reveal_zoom_controls(self, d, tap_coords): d.click(*tap_coords) time.sleep(1) + @staticmethod + def _control_label(node): + """Return a node's label from content-desc, or text when that is empty.""" + return node.attrib.get("content-desc") or node.attrib.get("text") or "" + def get_audio_control_info(self, d): """Return audio state and bounds by parsing the current hierarchy dump.""" try: @@ -154,11 +160,11 @@ def get_audio_control_info(self, d): return None, None, None for node in root.iter("node"): - content_desc = node.attrib.get("content-desc", "") - if content_desc == "Mute my audio, button": - return True, node.attrib.get("bounds"), content_desc - if content_desc == "Unmute my audio, button": - return False, node.attrib.get("bounds"), content_desc + label = self._control_label(node) + if label.startswith("Mute my audio"): + return True, node.attrib.get("bounds"), label + if label.startswith("Unmute my audio"): + return False, node.attrib.get("bounds"), label return None, None, None @@ -173,11 +179,11 @@ def get_video_control_info(self, d): return None, None, None for node in root.iter("node"): - content_desc = node.attrib.get("content-desc", "") - if content_desc == "Start my video, button": - return False, node.attrib.get("bounds"), content_desc - if content_desc == "Stop my video, button": - return True, node.attrib.get("bounds"), content_desc + label = self._control_label(node) + if label.startswith("Start my video"): + return False, node.attrib.get("bounds"), label + if label.startswith("Stop my video"): + return True, node.attrib.get("bounds"), label return None, None, None @@ -192,9 +198,9 @@ def get_leave_control_info(self, d): return None, None for node in root.iter("node"): - content_desc = node.attrib.get("content-desc", "") - if content_desc == "Leave, button": - return node.attrib.get("bounds"), content_desc + label = self._control_label(node) + if label.startswith("Leave"): + return node.attrib.get("bounds"), label return None, None @@ -241,6 +247,60 @@ def set_device(self, serial): self.logger.error(f"Failed to connect: {e}") raise + def _adb(self, *args): + """Run an adb shell command on this device and return the result.""" + return subprocess.run( + ["adb", "-s", self.device_serial, "shell", *args], + capture_output=True, + text=True, + ) + + def grant_permissions(self, package_name=ZOOM_PACKAGE): + """Pre-grant the app's runtime permissions so no dialog blocks the join. + + Reads the permissions the package actually declares on this device, so + one call covers every API level in the fleet. Returns the number still + ungranted afterwards. + """ + self._set_phase("PERMISSIONS") + + listing = self._adb("dumpsys", "package", package_name).stdout + wanted, in_runtime = [], False + for raw in listing.splitlines(): + line = raw.strip() + if line.startswith("runtime permissions:"): + in_runtime = True + elif in_runtime: + if line.startswith("android.permission."): + wanted.append(line.split(":", 1)[0]) + elif line: + break + + granted = 0 + for permission in wanted: + result = self._adb("pm", "grant", package_name, permission) + output = result.stdout + result.stderr + if "GRANT_RUNTIME_PERMISSIONS" in output: + self.logger.error( + "This ROM blocks adb from granting permissions. " + "Accept the prompts manually once." + ) + break + if not output.strip(): + granted += 1 + + # Not a runtime permission — it is an appop, so pm grant cannot set it. + self._adb("appops", "set", package_name, "SYSTEM_ALERT_WINDOW", "allow") + + remaining = self._adb("dumpsys", "package", package_name).stdout.count( + "granted=false" + ) + self.logger.info( + f"Permissions: granted {granted}/{len(wanted)}, " + f"{remaining} still ungranted." + ) + return remaining + def start_interop_app(self): """Force-stop Zoom and hand the device back to the interop app. @@ -311,32 +371,14 @@ def join_zoom_meeting(self, meeting_url, participant_name): self.logger.info(f"Starting {ZOOM_PACKAGE} and opening the meeting link.") d.app_start(ZOOM_PACKAGE, stop=True) time.sleep(2) - self.adb_device.shell( - f'am start -a android.intent.action.VIEW -d "{meeting_url}"' + f'am start -a android.intent.action.VIEW -d "{meeting_url}" {ZOOM_PACKAGE}' ) self.logger.info(f"Meeting link handed to Zoom: {meeting_url}") time.sleep(8) - self._set_phase("PERMISSIONS") - self.logger.info("Checking for permission prompts.") - allow_while_using = d(text="While using the app") - if allow_while_using.wait(timeout=8): - allow_while_using.click() - self.logger.info("Granted 'While using the app'.") - time.sleep(2) - - for permission_text in ["Allow", "ALLOW"]: - allow_btn = d(text=permission_text, className="android.widget.Button") - if allow_btn.wait(timeout=5): - allow_btn.click() - self.logger.info(f"Clicked '{permission_text}'.") - time.sleep(1) - else: - self.logger.info("No permission prompt appeared; already granted.") - preview_join = d(text="Editing display name") - if preview_join.wait(timeout=5): + if preview_join.wait(timeout=30): self.logger.info("Preview screen detected.") name_input = d(className="android.widget.EditText") @@ -602,6 +644,21 @@ def enable_audio_video(self, d, max_retries=15, tap_coords=(500, 500)): self.logger.warning( f"Could not fully enable audio/video after {max_retries} retries." ) + self.logger.warning( + "Audio and video can only be toggled while Zoom's meeting " + "toolbar is on screen, and Zoom Auto hides it a few seconds after " + "each tap. Turn on 'Always show meeting controls' once on " + "this handset so the buttons stay readable — Zoom keeps the " + "setting across restarts:\n" + " 1. Open the Zoom app (no sign-in needed).\n" + " 2. Tap the gear icon at the top left.\n" + " 3. Tap 'Meetings'.\n" + " 4. Scroll down to 'IN MEETING CONTROLS'.\n" + " 5. Turn on 'Always show meeting controls'.\n" + "While on that screen, also leave 'Turn off my video' and " + "'Mute my microphone' off, so the client joins unmuted and " + "with video already on." + ) def upload_ping_log(self): """POST this participant's ping log to the host as a file upload. @@ -694,6 +751,7 @@ def main(): ) try: automator.set_device(args.serial) + automator.grant_permissions() automator.join_zoom_meeting(args.meeting_url, args.participant_name) except Exception as e: automator.logger.error(f"Error: {e}")