Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 91 additions & 33 deletions py-scripts/real_application_tests/zoom_automation/android_zoom.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import logging
import os
import re
import subprocess
import sys
import time
from datetime import datetime, timedelta
Expand Down Expand Up @@ -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:
Expand All @@ -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

Expand All @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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}")
Expand Down
Loading