xref: /aosp_15_r20/external/angle/src/tests/py_utils/android_helper.py (revision 8975f5c5ed3d1c378011245431ada316dfb6f244)
1# Copyright 2022 The ANGLE Project Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5import contextlib
6import functools
7import glob
8import hashlib
9import json
10import logging
11import os
12import pathlib
13import platform
14import posixpath
15import random
16import re
17import subprocess
18import sys
19import tarfile
20import tempfile
21import threading
22import time
23import zipfile
24
25import angle_path_util
26
27
28ANGLE_TRACE_TEST_SUITE = 'angle_trace_tests'
29
30
31class _Global(object):
32    initialized = False
33    is_android = False
34    current_suite = None
35    current_user = None
36    external_storage = None
37    has_adb_root = False
38    traces_outside_of_apk = False
39    base_dir = None
40    temp_dir = None
41    use_run_as = True
42
43    @classmethod
44    def IsMultiUser(cls):
45        assert cls.current_user != None, "Call _GetCurrentUser before using IsMultiUser"
46        return cls.current_user != '0'
47
48def _ApkPath(suite_name):
49    return os.path.join('%s_apk' % suite_name, '%s-debug.apk' % suite_name)
50
51
52def _RemovePrefix(str, prefix):
53    assert str.startswith(prefix), 'Expected prefix %s, got: %s' % (prefix, str)
54    return str[len(prefix):]
55
56
57def _InitializeAndroid(apk_path):
58    # Pull a few pieces of data with a single adb trip
59    shell_id, su_path, current_user, data_permissions = _AdbShell(
60        'id -u; which su || echo noroot; am get-current-user; stat --format %a /data').decode(
61        ).strip().split('\n')
62
63    # Populate globals with those results
64    _Global.has_adb_root = _GetAdbRoot(shell_id, su_path)
65    _Global.current_user = _GetCurrentUser(current_user)
66    _Global.use_run_as = _GetRunAs(data_permissions)
67
68    # Storage location varies by user
69    _Global.external_storage = '/storage/emulated/' + _Global.current_user + '/chromium_tests_root/'
70
71    # We use the app's home directory for storing several things
72    _Global.base_dir = '/data/user/' + _Global.current_user + '/com.android.angle.test/'
73
74    if _Global.has_adb_root:
75        # /data/local/tmp/ is not writable by apps.. So use the app path
76        _Global.temp_dir = _Global.base_dir + 'tmp/'
77        # Additionally, if we're not the default user, we need to use the app's dir for external storage
78        if _Global.IsMultiUser():
79            # TODO(b/361388557): Switch to a content provider for this, i.e. `content write`
80            logging.warning(
81                'Using app dir for external storage, may not work with chromium scripts, may require `setenforce 0`'
82            )
83            _Global.external_storage = _Global.base_dir + 'chromium_tests_root/'
84    else:
85        # /sdcard/ is slow (see https://crrev.com/c/3615081 for details)
86        # logging will be fully-buffered, can be truncated on crashes
87        _Global.temp_dir = '/storage/emulated/' + _Global.current_user + '/'
88
89    logging.debug('Temp dir: %s', _Global.temp_dir)
90    logging.debug('External storage: %s', _Global.external_storage)
91
92    with zipfile.ZipFile(apk_path) as zf:
93        apk_so_libs = [posixpath.basename(f) for f in zf.namelist() if f.endswith('.so')]
94
95    # When traces are outside of the apk this lib is also outside
96    interpreter_so_lib = 'libangle_trace_interpreter.so'
97    _Global.traces_outside_of_apk = interpreter_so_lib not in apk_so_libs
98
99    if logging.getLogger().isEnabledFor(logging.DEBUG):
100        logging.debug(_AdbShell('df -h').decode())
101
102
103def Initialize(suite_name):
104    if _Global.initialized:
105        return
106
107    apk_path = _ApkPath(suite_name)
108    if os.path.exists(apk_path):
109        _Global.is_android = True
110        _InitializeAndroid(apk_path)
111
112    _Global.initialized = True
113
114
115def IsAndroid():
116    assert _Global.initialized, 'Initialize not called'
117    return _Global.is_android
118
119
120def _EnsureTestSuite(suite_name):
121    assert IsAndroid()
122
123    if _Global.current_suite != suite_name:
124        PrepareTestSuite(suite_name)
125        _Global.current_suite = suite_name
126
127
128def _Run(cmd):
129    logging.debug('Executing command: %s', cmd)
130    startupinfo = None
131    if hasattr(subprocess, 'STARTUPINFO'):
132        # Prevent console window popping up on Windows
133        startupinfo = subprocess.STARTUPINFO()
134        startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
135        startupinfo.wShowWindow = subprocess.SW_HIDE
136    output = subprocess.check_output(cmd, startupinfo=startupinfo)
137    return output
138
139
140@functools.lru_cache()
141def FindAdb():
142    if platform.system() == 'Windows':
143        adb = 'adb.exe'  # from PATH
144    else:
145        platform_tools = (
146            pathlib.Path(angle_path_util.ANGLE_ROOT_DIR) / 'third_party' / 'android_sdk' /
147            'public' / 'platform-tools')
148        adb = str(platform_tools / 'adb') if platform_tools.exists() else 'adb'
149
150    adb_info = ', '.join(subprocess.check_output([adb, '--version']).decode().strip().split('\n'))
151    logging.info('adb --version: %s', adb_info)
152    return adb
153
154
155def _AdbRun(args):
156    return _Run([FindAdb()] + args)
157
158
159def _AdbShell(cmd):
160    output = _Run([FindAdb(), 'shell', cmd])
161    if platform.system() == 'Windows':
162        return output.replace(b'\r\n', b'\n')
163    return output
164
165
166def _GetAdbRoot(shell_id, su_path):
167    if int(shell_id) == 0:
168        logging.info('adb already got root')
169        return True
170
171    if su_path == 'noroot':
172        logging.warning('adb root not available on this device')
173        return False
174
175    logging.info('Getting adb root (may take a few seconds)')
176    _AdbRun(['root'])
177    for _ in range(20):  # `adb root` restarts adbd which can take quite a few seconds
178        time.sleep(0.5)
179        id_out = _AdbShell('id -u').decode('ascii').strip()
180        if id_out == '0':
181            logging.info('adb root succeeded')
182            return True
183
184    # Device has "su" but we couldn't get adb root. Something is wrong.
185    raise Exception('Failed to get adb root')
186
187
188def _GetRunAs(data_permissions):
189    # Determine run-as usage
190    if data_permissions.endswith('7'):
191        # run-as broken due to "/data readable or writable by others"
192        logging.warning('run-as not available due to /data permissions')
193        return False
194
195    if _Global.IsMultiUser():
196        # run-as is failing is the presence of multiple users
197        logging.warning('Disabling run-as for non-default user')
198        return False
199
200    return True
201
202
203def _GetCurrentUser(current_user):
204    # Ensure current user is clean
205    assert current_user.isnumeric(), current_user
206    logging.debug('Current user: %s', current_user)
207    return current_user
208
209
210def _ReadDeviceFile(device_path):
211    with _TempLocalFile() as tempfile_path:
212        _AdbRun(['pull', device_path, tempfile_path])
213        with open(tempfile_path, 'rb') as f:
214            return f.read()
215
216
217def _RemoveDeviceFile(device_path):
218    _AdbShell('rm -f ' + device_path + ' || true')  # ignore errors
219
220
221def _MakeTar(path, patterns):
222    with _TempLocalFile() as tempfile_path:
223        with tarfile.open(tempfile_path, 'w', format=tarfile.GNU_FORMAT) as tar:
224            for p in patterns:
225                for f in glob.glob(p, recursive=True):
226                    tar.add(f, arcname=f.replace('../../', ''))
227        _AdbRun(['push', tempfile_path, path])
228
229
230def _AddRestrictedTracesJson():
231    _MakeTar(_Global.external_storage + 't.tar', [
232        '../../src/tests/restricted_traces/*/*.json',
233        'gen/trace_list.json',
234    ])
235    _AdbShell('r=' + _Global.external_storage + '; tar -xf $r/t.tar -C $r/ && rm $r/t.tar')
236
237
238def _AddDeqpFiles(suite_name):
239    patterns = [
240        '../../third_party/VK-GL-CTS/src/external/openglcts/data/gl_cts/data/mustpass/*/*/main/*.txt',
241        '../../src/tests/deqp_support/*.txt'
242    ]
243    if '_gles2_' in suite_name:
244        patterns.append('gen/vk_gl_cts_data/data/gles2/**')
245    elif '_gles3_' in suite_name:
246        patterns.append('gen/vk_gl_cts_data/data/gles3/**')
247        patterns.append('gen/vk_gl_cts_data/data/gl_cts/data/gles3/**')
248    elif '_gles31_' in suite_name:
249        patterns.append('gen/vk_gl_cts_data/data/gles31/**')
250        patterns.append('gen/vk_gl_cts_data/data/gl_cts/data/gles31/**')
251    elif '_gles32_' in suite_name:
252        patterns.append('gen/vk_gl_cts_data/data/gl_cts/data/gles32/**')
253    else:
254        # Harness crashes if vk_gl_cts_data/data dir doesn't exist, so add a file
255        patterns.append('gen/vk_gl_cts_data/data/gles2/data/brick.png')
256
257    _MakeTar(_Global.external_storage + 'deqp.tar', patterns)
258    _AdbShell('r=' + _Global.external_storage + '; tar -xf $r/deqp.tar -C $r/ && rm $r/deqp.tar')
259
260
261def _GetDeviceApkPath():
262    pm_path = _AdbShell('pm path com.android.angle.test || true').decode().strip()
263    if not pm_path:
264        logging.debug('No installed path found for com.android.angle.test')
265        return None
266    device_apk_path = _RemovePrefix(pm_path, 'package:')
267    logging.debug('Device APK path is %s' % device_apk_path)
268    return device_apk_path
269
270
271def _LocalFileHash(local_path, gz_tail_size):
272    h = hashlib.sha256()
273    with open(local_path, 'rb') as f:
274        if local_path.endswith('.gz'):
275            # equivalent of tail -c {gz_tail_size}
276            offset = os.path.getsize(local_path) - gz_tail_size
277            if offset > 0:
278                f.seek(offset)
279        for data in iter(lambda: f.read(65536), b''):
280            h.update(data)
281    return h.hexdigest()
282
283
284def _CompareHashes(local_path, device_path):
285    # The last 8 bytes of gzip contain CRC-32 and the initial file size and the preceding
286    # bytes should be affected by changes in the middle if we happen to run into a collision
287    gz_tail_size = 4096
288
289    if local_path.endswith('.gz'):
290        cmd = 'test -f {path} && tail -c {gz_tail_size} {path} | sha256sum -b || true'.format(
291            path=device_path, gz_tail_size=gz_tail_size)
292    else:
293        cmd = 'test -f {path} && sha256sum -b {path} || true'.format(path=device_path)
294
295    if _Global.use_run_as and device_path.startswith('/data'):
296        # Use run-as for files that reside on /data, which aren't accessible without root
297        cmd = "run-as com.android.angle.test sh -c '{cmd}'".format(cmd=cmd)
298
299    device_hash = _AdbShell(cmd).decode().strip()
300    if not device_hash:
301        logging.debug('_CompareHashes: File not found on device')
302        return False  # file not on device
303
304    return _LocalFileHash(local_path, gz_tail_size) == device_hash
305
306
307def _CheckSameApkInstalled(apk_path):
308    device_apk_path = _GetDeviceApkPath()
309
310    try:
311        if device_apk_path and _CompareHashes(apk_path, device_apk_path):
312            return True
313    except subprocess.CalledProcessError as e:
314        # non-debuggable test apk installed on device breaks run-as
315        logging.warning('_CompareHashes of apk failed: %s' % e)
316
317    return False
318
319
320def PrepareTestSuite(suite_name):
321    apk_path = _ApkPath(suite_name)
322
323    if _CheckSameApkInstalled(apk_path):
324        logging.info('Skipping APK install because host and device hashes match')
325    else:
326        logging.info('Installing apk path=%s size=%s' % (apk_path, os.path.getsize(apk_path)))
327        _AdbRun(['install', '-r', '-d', apk_path])
328
329    permissions = [
330        'android.permission.CAMERA', 'android.permission.CHANGE_CONFIGURATION',
331        'android.permission.READ_EXTERNAL_STORAGE', 'android.permission.RECORD_AUDIO',
332        'android.permission.WRITE_EXTERNAL_STORAGE'
333    ]
334    _AdbShell('for q in %s;do pm grant com.android.angle.test "$q";done;' %
335              (' '.join(permissions)))
336
337    _AdbShell('appops set com.android.angle.test MANAGE_EXTERNAL_STORAGE allow || true')
338
339    _AdbShell('mkdir -p ' + _Global.external_storage)
340    _AdbShell('mkdir -p %s' % _Global.temp_dir)
341
342    if suite_name == ANGLE_TRACE_TEST_SUITE:
343        _AddRestrictedTracesJson()
344
345    if '_deqp_' in suite_name:
346        _AddDeqpFiles(suite_name)
347
348    if suite_name == 'angle_end2end_tests':
349        _AdbRun([
350            'push', '../../src/tests/angle_end2end_tests_expectations.txt',
351            _Global.external_storage + 'src/tests/angle_end2end_tests_expectations.txt'
352        ])
353
354
355def PrepareRestrictedTraces(traces):
356    start = time.time()
357    total_size = 0
358    skipped = 0
359
360    # In order to get files to the app's home directory and loadable as libraries, we must first
361    # push them to tmp on the device.  We then use `run-as` which allows copying files from tmp.
362    # Note that `mv` is not allowed with `run-as`.  This means there will briefly be two copies
363    # of the trace on the device, so keep that in mind as space becomes a problem in the future.
364    app_tmp_path = '/data/local/tmp/angle_traces/'
365
366    if _Global.use_run_as:
367        _AdbShell('mkdir -p ' + app_tmp_path +
368                  ' && run-as com.android.angle.test mkdir -p angle_traces')
369    else:
370        _AdbShell('mkdir -p ' + app_tmp_path + ' ' + _Global.base_dir + 'angle_traces/')
371
372    def _HashesMatch(local_path, device_path):
373        nonlocal total_size, skipped
374        if _CompareHashes(local_path, device_path):
375            skipped += 1
376            return True
377        else:
378            total_size += os.path.getsize(local_path)
379            return False
380
381    def _Push(local_path, path_from_root):
382        device_path = _Global.external_storage + path_from_root
383        if not _HashesMatch(local_path, device_path):
384            _AdbRun(['push', local_path, device_path])
385
386    def _PushLibToAppDir(lib_name):
387        local_path = lib_name
388        if not os.path.exists(local_path):
389            print('Error: missing library: ' + local_path)
390            print('Is angle_restricted_traces set in gn args?')  # b/294861737
391            sys.exit(1)
392
393        device_path = _Global.base_dir + 'angle_traces/' + lib_name
394        if _HashesMatch(local_path, device_path):
395            return
396
397        if _Global.use_run_as:
398            tmp_path = posixpath.join(app_tmp_path, lib_name)
399            logging.debug('_PushToAppDir: Pushing %s to %s' % (local_path, tmp_path))
400            try:
401                _AdbRun(['push', local_path, tmp_path])
402                _AdbShell('run-as com.android.angle.test cp ' + tmp_path + ' ./angle_traces/')
403                _AdbShell('rm ' + tmp_path)
404            finally:
405                _RemoveDeviceFile(tmp_path)
406        else:
407            _AdbRun(['push', local_path, _Global.base_dir + 'angle_traces/'])
408
409    # Set up each trace
410    for idx, trace in enumerate(sorted(traces)):
411        logging.info('Syncing %s trace (%d/%d)', trace, idx + 1, len(traces))
412
413        path_from_root = 'src/tests/restricted_traces/' + trace + '/' + trace + '.angledata.gz'
414        _Push('../../' + path_from_root, path_from_root)
415
416        if _Global.traces_outside_of_apk:
417            lib_name = 'libangle_restricted_traces_' + trace + '.so'
418            _PushLibToAppDir(lib_name)
419
420        tracegz = 'gen/tracegz_' + trace + '.gz'
421        if os.path.exists(tracegz):  # Requires angle_enable_tracegz
422            _Push(tracegz, tracegz)
423
424    # Push one additional file when running outside the APK
425    if _Global.traces_outside_of_apk:
426        _PushLibToAppDir('libangle_trace_interpreter.so')
427
428    logging.info('Synced files for %d traces (%.1fMB, %d files already ok) in %.1fs', len(traces),
429                 total_size / 1e6, skipped,
430                 time.time() - start)
431
432
433def _RandomHex():
434    return hex(random.randint(0, 2**64))[2:]
435
436
437@contextlib.contextmanager
438def _TempDeviceDir():
439    path = posixpath.join(_Global.temp_dir, 'temp_dir-%s' % _RandomHex())
440    _AdbShell('mkdir -p ' + path)
441    try:
442        yield path
443    finally:
444        _AdbShell('rm -rf ' + path)
445
446
447@contextlib.contextmanager
448def _TempDeviceFile():
449    path = posixpath.join(_Global.temp_dir, 'temp_file-%s' % _RandomHex())
450    try:
451        yield path
452    finally:
453        _AdbShell('rm -f ' + path)
454
455
456@contextlib.contextmanager
457def _TempLocalFile():
458    fd, path = tempfile.mkstemp()
459    os.close(fd)
460    try:
461        yield path
462    finally:
463        os.remove(path)
464
465
466def _SetCaptureProps(env, device_out_dir):
467    capture_var_map = {  # src/libANGLE/capture/FrameCapture.cpp
468        'ANGLE_CAPTURE_ENABLED': 'debug.angle.capture.enabled',
469        'ANGLE_CAPTURE_FRAME_START': 'debug.angle.capture.frame_start',
470        'ANGLE_CAPTURE_FRAME_END': 'debug.angle.capture.frame_end',
471        'ANGLE_CAPTURE_TRIGGER': 'debug.angle.capture.trigger',
472        'ANGLE_CAPTURE_LABEL': 'debug.angle.capture.label',
473        'ANGLE_CAPTURE_COMPRESSION': 'debug.angle.capture.compression',
474        'ANGLE_CAPTURE_VALIDATION': 'debug.angle.capture.validation',
475        'ANGLE_CAPTURE_VALIDATION_EXPR': 'debug.angle.capture.validation_expr',
476        'ANGLE_CAPTURE_SOURCE_EXT': 'debug.angle.capture.source_ext',
477        'ANGLE_CAPTURE_SOURCE_SIZE': 'debug.angle.capture.source_size',
478        'ANGLE_CAPTURE_FORCE_SHADOW': 'debug.angle.capture.force_shadow',
479    }
480    empty_value = '""'
481    shell_cmds = [
482        # out_dir is special because the corresponding env var is a host path not a device path
483        'setprop debug.angle.capture.out_dir ' + (device_out_dir or empty_value),
484    ] + [
485        'setprop %s %s' % (v, env.get(k, empty_value)) for k, v in sorted(capture_var_map.items())
486    ]
487
488    _AdbShell('\n'.join(shell_cmds))
489
490
491def _RunInstrumentation(flags):
492    with _TempDeviceFile() as temp_device_file:
493        cmd = r'''
494am instrument --user {user} -w \
495    -e org.chromium.native_test.NativeTestInstrumentationTestRunner.StdoutFile {out} \
496    -e org.chromium.native_test.NativeTest.CommandLineFlags "{flags}" \
497    -e org.chromium.native_test.NativeTestInstrumentationTestRunner.ShardNanoTimeout "1000000000000000000" \
498    -e org.chromium.native_test.NativeTestInstrumentationTestRunner.NativeTestActivity \
499    com.android.angle.test.AngleUnitTestActivity \
500    com.android.angle.test/org.chromium.build.gtest_apk.NativeTestInstrumentationTestRunner
501        '''.format(
502            user=_Global.current_user, out=temp_device_file, flags=r' '.join(flags)).strip()
503
504        capture_out_dir = os.environ.get('ANGLE_CAPTURE_OUT_DIR')
505        if capture_out_dir:
506            assert os.path.isdir(capture_out_dir)
507            with _TempDeviceDir() as device_out_dir:
508                _SetCaptureProps(os.environ, device_out_dir)
509                try:
510                    _AdbShell(cmd)
511                finally:
512                    _SetCaptureProps({}, None)  # reset
513                _PullDir(device_out_dir, capture_out_dir)
514        else:
515            _AdbShell(cmd)
516        return _ReadDeviceFile(temp_device_file)
517
518
519def AngleSystemInfo(args):
520    _EnsureTestSuite('angle_system_info_test')
521
522    with _TempDeviceDir() as temp_dir:
523        _RunInstrumentation(args + ['--render-test-output-dir=' + temp_dir])
524        output_file = posixpath.join(temp_dir, 'angle_system_info.json')
525        return json.loads(_ReadDeviceFile(output_file))
526
527
528def GetBuildFingerprint():
529    return _AdbShell('getprop ro.build.fingerprint').decode('ascii').strip()
530
531
532def _PullDir(device_dir, local_dir):
533    files = _AdbShell('ls -1 %s' % device_dir).decode('ascii').split('\n')
534    for f in files:
535        f = f.strip()
536        if f:
537            _AdbRun(['pull', posixpath.join(device_dir, f), posixpath.join(local_dir, f)])
538
539
540def _RemoveFlag(args, f):
541    matches = [a for a in args if a.startswith(f + '=')]
542    assert len(matches) <= 1
543    if matches:
544        original_value = matches[0].split('=')[1]
545        args.remove(matches[0])
546    else:
547        original_value = None
548
549    return original_value
550
551
552def RunTests(test_suite, args, stdoutfile=None, log_output=True):
553    _EnsureTestSuite(test_suite)
554
555    args = args[:]
556    test_output_path = _RemoveFlag(args, '--isolated-script-test-output')
557    perf_output_path = _RemoveFlag(args, '--isolated-script-test-perf-output')
558    test_output_dir = _RemoveFlag(args, '--render-test-output-dir')
559
560    result = 0
561    output = b''
562    output_json = {}
563    try:
564        with contextlib.ExitStack() as stack:
565            device_test_output_path = stack.enter_context(_TempDeviceFile())
566            args.append('--isolated-script-test-output=' + device_test_output_path)
567
568            if perf_output_path:
569                device_perf_path = stack.enter_context(_TempDeviceFile())
570                args.append('--isolated-script-test-perf-output=%s' % device_perf_path)
571
572            if test_output_dir:
573                assert os.path.isdir(test_output_dir), 'Dir does not exist: %s' % test_output_dir
574                device_output_dir = stack.enter_context(_TempDeviceDir())
575                args.append('--render-test-output-dir=' + device_output_dir)
576
577            output = _RunInstrumentation(args)
578
579            if '--list-tests' in args:
580                # When listing tests, there may be no output file. We parse stdout anyways.
581                test_output = b'{"interrupted": false}'
582            else:
583                try:
584                    test_output = _ReadDeviceFile(device_test_output_path)
585                except subprocess.CalledProcessError:
586                    logging.error('Unable to read test json output. Stdout:\n%s', output.decode())
587                    result = 1
588                    return result, output.decode(), None
589
590            if test_output_path:
591                with open(test_output_path, 'wb') as f:
592                    f.write(test_output)
593
594            output_json = json.loads(test_output)
595
596            num_failures = output_json.get('num_failures_by_type', {}).get('FAIL', 0)
597            interrupted = output_json.get('interrupted', True)  # Normally set to False
598            if num_failures != 0 or interrupted or output_json.get('is_unexpected', False):
599                logging.error('Tests failed: %s', test_output.decode())
600                result = 1
601
602            if test_output_dir:
603                _PullDir(device_output_dir, test_output_dir)
604
605            if perf_output_path:
606                _AdbRun(['pull', device_perf_path, perf_output_path])
607
608        if log_output:
609            logging.info(output.decode())
610
611        if stdoutfile:
612            with open(stdoutfile, 'wb') as f:
613                f.write(output)
614    except Exception as e:
615        logging.exception(e)
616        result = 1
617
618    return result, output.decode(), output_json
619
620
621def GetTraceFromTestName(test_name):
622    if test_name.startswith('TraceTest.'):
623        return test_name[len('TraceTest.'):]
624    return None
625
626
627def GetTemps():
628    temps = _AdbShell(
629        'cat /dev/thermal/tz-by-name/*_therm/temp 2>/dev/null || true').decode().split()
630    logging.debug('tz-by-name temps: %s' % ','.join(temps))
631
632    temps_celsius = []
633    for t in temps:
634        try:
635            temps_celsius.append(float(t) / 1e3)
636        except ValueError:
637            pass
638
639    return temps_celsius
640