xref: /aosp_15_r20/cts/apps/CameraITS/utils/ui_interaction_utils.py (revision b7c941bb3fa97aba169d73cee0bed2de8ac964bf)
1# Copyright 2024 The Android Open Source Project
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#      http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14"""Utility functions for interacting with a device via the UI."""
15
16import datetime
17import logging
18import re
19import time
20import types
21import xml.etree.ElementTree as et
22
23import camera_properties_utils
24import its_device_utils
25
26_DIR_EXISTS_TXT = 'Directory exists'
27_PERMISSIONS_LIST = ('CAMERA', 'RECORD_AUDIO', 'ACCESS_FINE_LOCATION',
28                     'ACCESS_COARSE_LOCATION')
29
30ACTION_ITS_DO_JCA_CAPTURE = (
31    'com.android.cts.verifier.camera.its.ACTION_ITS_DO_JCA_CAPTURE'
32)
33ACTION_ITS_DO_JCA_VIDEO_CAPTURE = (
34    'com.android.cts.verifier.camera.its.ACTION_ITS_DO_JCA_VIDEO_CAPTURE'
35)
36ACTIVITY_WAIT_TIME_SECONDS = 5
37AGREE_BUTTON = 'Agree'
38AGREE_AND_CONTINUE_BUTTON = 'Agree and continue'
39CANCEL_BUTTON_TXT = 'Cancel'
40CAMERA_FILES_PATHS = ('/sdcard/DCIM/Camera',
41                      '/storage/emulated/0/Pictures')
42CAPTURE_BUTTON_RESOURCE_ID = 'CaptureButton'
43DONE_BUTTON_TXT = 'Done'
44FLASH_MODE_TO_CLICKS = types.MappingProxyType({
45    'OFF': 3,
46    'AUTO': 2
47})
48IMG_CAPTURE_CMD = 'am start -a android.media.action.IMAGE_CAPTURE'
49ITS_ACTIVITY_TEXT = 'Camera ITS Test'
50JPG_FORMAT_STR = '.jpg'
51OK_BUTTON_TXT = 'OK'
52TAKE_PHOTO_CMD = 'input keyevent KEYCODE_CAMERA'
53QUICK_SETTINGS_RESOURCE_ID = 'QuickSettingsDropDown'
54QUICK_SET_FLASH_RESOURCE_ID = 'QuickSettingsFlashButton'
55QUICK_SET_FLIP_CAMERA_RESOURCE_ID = 'QuickSettingsFlipCameraButton'
56QUICK_SET_RATIO_RESOURCE_ID = 'QuickSettingsRatioButton'
57RATIO_TO_UI_DESCRIPTION = {
58    '1 to 1 aspect ratio': 'QuickSettingsRatio1:1Button',
59    '3 to 4 aspect ratio': 'QuickSettingsRatio3:4Button',
60    '9 to 16 aspect ratio': 'QuickSettingsRatio9:16Button'
61}
62REMOVE_CAMERA_FILES_CMD = 'rm '
63UI_DESCRIPTION_BACK_CAMERA = 'Back Camera'
64UI_DESCRIPTION_FRONT_CAMERA = 'Front Camera'
65UI_OBJECT_WAIT_TIME_SECONDS = datetime.timedelta(seconds=3)
66VIEWFINDER_NOT_VISIBLE_PREFIX = 'viewfinder_not_visible'
67VIEWFINDER_VISIBLE_PREFIX = 'viewfinder_visible'
68WAIT_INTERVAL_FIVE_SECONDS = datetime.timedelta(seconds=5)
69
70
71def _find_ui_object_else_click(object_to_await, object_to_click):
72  """Waits for a UI object to be visible. If not, clicks another UI object.
73
74  Args:
75    object_to_await: A snippet-uiautomator selector object to be awaited.
76    object_to_click: A snippet-uiautomator selector object to be clicked.
77  """
78  if not object_to_await.wait.exists(UI_OBJECT_WAIT_TIME_SECONDS):
79    object_to_click.click()
80
81
82def verify_ui_object_visible(ui_object, call_on_fail=None):
83  """Verifies that a UI object is visible.
84
85  Args:
86    ui_object: A snippet-uiautomator selector object.
87    call_on_fail: [Optional] Callable; method to call on failure.
88  """
89  ui_object_visible = ui_object.wait.exists(UI_OBJECT_WAIT_TIME_SECONDS)
90  if not ui_object_visible:
91    if call_on_fail is not None:
92      call_on_fail()
93    raise AssertionError('UI object was not visible!')
94
95
96def open_jca_viewfinder(dut, log_path, request_video_capture=False):
97  """Sends an intent to JCA and open its viewfinder.
98
99  Args:
100    dut: An Android controller device object.
101    log_path: str; Log path to save screenshots.
102    request_video_capture: boolean; True if requesting video capture.
103  Raises:
104    AssertionError: If JCA viewfinder is not visible.
105  """
106  its_device_utils.start_its_test_activity(dut.serial)
107  call_on_fail = lambda: dut.take_screenshot(log_path, prefix='its_not_found')
108  verify_ui_object_visible(
109      dut.ui(text=ITS_ACTIVITY_TEXT),
110      call_on_fail=call_on_fail
111  )
112
113  # Send intent to ItsTestActivity, which will start the correct JCA activity.
114  if request_video_capture:
115    its_device_utils.run(
116        f'adb -s {dut.serial} shell am broadcast -a'
117        f'{ACTION_ITS_DO_JCA_VIDEO_CAPTURE}'
118    )
119  else:
120    its_device_utils.run(
121        f'adb -s {dut.serial} shell am broadcast -a'
122        f'{ACTION_ITS_DO_JCA_CAPTURE}'
123    )
124  jca_capture_button_visible = dut.ui(
125      res=CAPTURE_BUTTON_RESOURCE_ID).wait.exists(
126          UI_OBJECT_WAIT_TIME_SECONDS)
127  if not jca_capture_button_visible:
128    dut.take_screenshot(log_path, prefix=VIEWFINDER_NOT_VISIBLE_PREFIX)
129    logging.debug('Current UI dump: %s', dut.ui.dump())
130    raise AssertionError('JCA was not started successfully!')
131  dut.take_screenshot(log_path, prefix=VIEWFINDER_VISIBLE_PREFIX)
132
133
134def switch_jca_camera(dut, log_path, facing):
135  """Interacts with JCA UI to switch camera if necessary.
136
137  Args:
138    dut: An Android controller device object.
139    log_path: str; log path to save screenshots.
140    facing: str; constant describing the direction the camera lens faces.
141  Raises:
142    AssertionError: If JCA does not report that camera has been switched.
143  """
144  if facing == camera_properties_utils.LENS_FACING['BACK']:
145    ui_facing_description = UI_DESCRIPTION_BACK_CAMERA
146  elif facing == camera_properties_utils.LENS_FACING['FRONT']:
147    ui_facing_description = UI_DESCRIPTION_FRONT_CAMERA
148  else:
149    raise ValueError(f'Unknown facing: {facing}')
150  dut.ui(res=QUICK_SETTINGS_RESOURCE_ID).click()
151  _find_ui_object_else_click(dut.ui(desc=ui_facing_description),
152                             dut.ui(res=QUICK_SET_FLIP_CAMERA_RESOURCE_ID))
153  if not dut.ui(desc=ui_facing_description).wait.exists(
154      UI_OBJECT_WAIT_TIME_SECONDS):
155    dut.take_screenshot(log_path, prefix='failed_to_switch_camera')
156    logging.debug('JCA UI dump: %s', dut.ui.dump())
157    raise AssertionError(f'Failed to switch to {ui_facing_description}!')
158  dut.take_screenshot(
159      log_path, prefix=f"switched_to_{ui_facing_description.replace(' ', '_')}"
160  )
161  dut.ui(res=QUICK_SETTINGS_RESOURCE_ID).click()
162
163
164def change_jca_aspect_ratio(dut, log_path, aspect_ratio):
165  """Interacts with JCA UI to change aspect ratio if necessary.
166
167  Args:
168    dut: An Android controller device object.
169    log_path: str; log path to save screenshots.
170    aspect_ratio: str; Aspect ratio that JCA supports.
171      Acceptable values: _RATIO_TO_UI_DESCRIPTION
172  Raises:
173    ValueError: If ratio is not supported in JCA.
174    AssertionError: If JCA does not find the requested ratio.
175  """
176  if aspect_ratio not in RATIO_TO_UI_DESCRIPTION:
177    raise ValueError(f'Testing ratio {aspect_ratio} not supported in JCA!')
178  dut.ui(res=QUICK_SETTINGS_RESOURCE_ID).click()
179  # Change aspect ratio in ratio switching menu if needed
180  if not dut.ui(desc=aspect_ratio).wait.exists(UI_OBJECT_WAIT_TIME_SECONDS):
181    dut.ui(res=QUICK_SET_RATIO_RESOURCE_ID).click()
182    try:
183      dut.ui(res=RATIO_TO_UI_DESCRIPTION[aspect_ratio]).click()
184    except Exception as e:
185      dut.take_screenshot(
186          log_path, prefix=f'failed_to_find{aspect_ratio.replace(" ", "_")}'
187      )
188      raise AssertionError(
189          f'Testing ratio {aspect_ratio} not found in JCA app UI!') from e
190  dut.ui(res=QUICK_SETTINGS_RESOURCE_ID).click()
191
192
193def do_jca_video_setup(dut, log_path, facing, aspect_ratio):
194  """Change video capture settings using the UI.
195
196  Selects UI elements to modify settings.
197
198  Args:
199    dut: An Android controller device object.
200    log_path: str; log path to save screenshots.
201    facing: str; constant describing the direction the camera lens faces.
202      Acceptable values: camera_properties_utils.LENS_FACING[BACK, FRONT]
203    aspect_ratio: str; Aspect ratios that JCA supports.
204      Acceptable values: _RATIO_TO_UI_DESCRIPTION
205  """
206  open_jca_viewfinder(dut, log_path, request_video_capture=True)
207  switch_jca_camera(dut, log_path, facing)
208  change_jca_aspect_ratio(dut, log_path, aspect_ratio)
209
210
211def default_camera_app_setup(device_id, pkg_name):
212  """Setup Camera app by providing required permissions.
213
214  Args:
215    device_id: serial id of device.
216    pkg_name: pkg name of the app to setup.
217  Returns:
218    Runtime exception from called function or None.
219  """
220  logging.debug('Setting up the app with permission.')
221  for permission in _PERMISSIONS_LIST:
222    cmd = f'pm grant {pkg_name} android.permission.{permission}'
223    its_device_utils.run_adb_shell_command(device_id, cmd)
224  allow_manage_storage_cmd = (
225      f'appops set {pkg_name} MANAGE_EXTERNAL_STORAGE allow'
226  )
227  its_device_utils.run_adb_shell_command(device_id, allow_manage_storage_cmd)
228
229
230def switch_default_camera(dut, facing, log_path):
231  """Interacts with default camera app UI to switch camera.
232
233  Args:
234    dut: An Android controller device object.
235    facing: str; constant describing the direction the camera lens faces.
236    log_path: str; log path to save screenshots.
237  Raises:
238    AssertionError: If default camera app does not report that
239      camera has been switched.
240  """
241  flip_camera_pattern = (
242      r'(switch to|flip camera|switch camera|camera switch|switch)'
243    )
244  default_ui_dump = dut.ui.dump()
245  logging.debug('Default camera UI dump: %s', default_ui_dump)
246  root = et.fromstring(default_ui_dump)
247  camera_flip_res = False
248  for node in root.iter('node'):
249    resource_id = node.get('resource-id')
250    content_desc = node.get('content-desc')
251    if re.search(
252        flip_camera_pattern, content_desc, re.IGNORECASE
253    ):
254      logging.debug('Pattern matches')
255      logging.debug('Resource id: %s', resource_id)
256      logging.debug('Flip camera content-desc: %s', content_desc)
257      camera_flip_res = True
258      break
259  if content_desc and resource_id:
260    if facing == 'front' and camera_flip_res:
261      if ('rear' in content_desc.lower() or 'rear' in resource_id.lower()
262          or 'back' in content_desc.lower() or 'back' in resource_id.lower()
263          ):
264        logging.debug('Pattern found but camera is already switched.')
265      else:
266        dut.ui(desc=content_desc).click.wait()
267    elif facing == 'rear' and camera_flip_res:
268      if 'front' in content_desc.lower() or 'front' in resource_id.lower():
269        logging.debug('Pattern found but camera is already switched.')
270      else:
271        dut.ui(desc=content_desc).click.wait()
272    else:
273      raise ValueError(f'Unknown facing: {facing}')
274
275  dut.take_screenshot(
276      log_path, prefix=f'switched_to_{facing}_default_camera'
277  )
278
279  if not camera_flip_res:
280    raise AssertionError('Flip camera resource not found.')
281
282
283def pull_img_files(device_id, input_path, output_path):
284  """Pulls files from the input_path on the device to output_path.
285
286  Args:
287    device_id: serial id of device.
288    input_path: File location on device.
289    output_path: Location to save the file on the host.
290  """
291  logging.debug('Pulling files from the device')
292  pull_cmd = f'adb -s {device_id} pull {input_path} {output_path}'
293  its_device_utils.run(pull_cmd)
294
295
296def launch_and_take_capture(dut, pkg_name, camera_facing, log_path):
297  """Launches the camera app and takes still capture.
298
299  Args:
300    dut: An Android controller device object.
301    pkg_name: pkg_name of the default camera app to
302      be used for captures.
303    camera_facing: camera lens facing orientation
304    log_path: str; log path to save screenshots.
305
306  Returns:
307    img_path_on_dut: Path of the captured image on the device
308  """
309  device_id = dut.serial
310  try:
311    logging.debug('Launching app: %s', pkg_name)
312    launch_cmd = f'monkey -p {pkg_name} 1'
313    its_device_utils.run_adb_shell_command(device_id, launch_cmd)
314
315    # Click OK/Done button on initial pop up windows
316    if dut.ui(text=AGREE_BUTTON).wait.exists(
317        timeout=WAIT_INTERVAL_FIVE_SECONDS):
318      dut.ui(text=AGREE_BUTTON).click.wait()
319    if dut.ui(text=AGREE_AND_CONTINUE_BUTTON).wait.exists(
320        timeout=WAIT_INTERVAL_FIVE_SECONDS):
321      dut.ui(text=AGREE_AND_CONTINUE_BUTTON).click.wait()
322    if dut.ui(text=OK_BUTTON_TXT).wait.exists(
323        timeout=WAIT_INTERVAL_FIVE_SECONDS):
324      dut.ui(text=OK_BUTTON_TXT).click.wait()
325    if dut.ui(text=DONE_BUTTON_TXT).wait.exists(
326        timeout=WAIT_INTERVAL_FIVE_SECONDS):
327      dut.ui(text=DONE_BUTTON_TXT).click.wait()
328    if dut.ui(text=CANCEL_BUTTON_TXT).wait.exists(
329        timeout=WAIT_INTERVAL_FIVE_SECONDS):
330      dut.ui(text=CANCEL_BUTTON_TXT).click.wait()
331    switch_default_camera(dut, camera_facing, log_path)
332    time.sleep(ACTIVITY_WAIT_TIME_SECONDS)
333    logging.debug('Taking photo')
334    its_device_utils.run_adb_shell_command(device_id, TAKE_PHOTO_CMD)
335    time.sleep(ACTIVITY_WAIT_TIME_SECONDS)
336    img_path_on_dut = ''
337    photo_storage_path = ''
338    for path in CAMERA_FILES_PATHS:
339      check_path_cmd = (
340          f'ls {path} && echo "Directory exists" || '
341          'echo "Directory does not exist"'
342      )
343      cmd_output = dut.adb.shell(check_path_cmd).decode('utf-8').strip()
344      if _DIR_EXISTS_TXT in cmd_output:
345        photo_storage_path = path
346        break
347    find_file_path = (
348        f'find {photo_storage_path} ! -empty -a ! -name \'.pending*\''
349        ' -a -type f -iname "*.jpg" -o -iname "*.jpeg"'
350    )
351    img_path_on_dut = (
352        dut.adb.shell(find_file_path).decode('utf-8').strip().lower()
353    )
354    logging.debug('Image path on DUT: %s', img_path_on_dut)
355    if JPG_FORMAT_STR not in img_path_on_dut:
356      raise AssertionError('Failed to find jpg files!')
357  finally:
358    force_stop_app(dut, pkg_name)
359  return img_path_on_dut
360
361
362def force_stop_app(dut, pkg_name):
363  """Force stops an app with given pkg_name.
364
365  Args:
366    dut: An Android controller device object.
367    pkg_name: pkg_name of the app to be stopped.
368  """
369  logging.debug('Closing app: %s', pkg_name)
370  force_stop_cmd = f'am force-stop {pkg_name}'
371  dut.adb.shell(force_stop_cmd)
372
373
374def default_camera_app_dut_setup(device_id, pkg_name):
375  """Setup the device for testing default camera app.
376
377  Args:
378    device_id: serial id of device.
379    pkg_name: pkg_name of the app.
380  Returns:
381    Runtime exception from called function or None.
382  """
383  default_camera_app_setup(device_id, pkg_name)
384  for path in CAMERA_FILES_PATHS:
385    its_device_utils.run_adb_shell_command(
386        device_id, f'{REMOVE_CAMERA_FILES_CMD}{path}/*')
387