1#!/usr/bin/env python 2# 3# Copyright (C) 2019 The Android Open Source Project 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16"""Semi-automatic AAE BugReport App test utility. 17 18It automates most of mundane steps when testing AAE BugReport app, but still 19requires manual input from a tester. 20 21How it works: 221. Runs adb as root. 232. Enables airplane mode to disable Internet. 243. Delete all the old bug reports. 254. Starts BugReport activity. 265. Waits 15 seconds and gets MetaBugReport from sqlite3. 276. Waits until dumpstate finishes. Timeouts after 10 minutes. 287. Writes bugreport, image and audio files to `bugreport-app-data/` directory. 298. Disables airplane mode to enable Internet. 309. Waits until bugreport is uploaded. Timeouts after 3 minutes. 3110. Prints results. 32""" 33 34from __future__ import absolute_import 35from __future__ import division 36from __future__ import print_function 37from __future__ import unicode_literals 38 39import argparse 40from collections import namedtuple 41import os 42import re 43import subprocess 44import sys 45import shutil 46import sqlite3 47import tempfile 48import time 49import zipfile 50 51VERSION = '0.2.0' 52 53BUGREPORT_PACKAGE = 'com.google.android.car.bugreport' 54PENDING_BUGREPORTS_DIR = ('/data/user/0/%s/bug_reports_pending' % 55 BUGREPORT_PACKAGE) 56SQLITE_DB_DIR = '/data/user/0/%s/databases' % BUGREPORT_PACKAGE 57SQLITE_DB_PATH = SQLITE_DB_DIR + '/bugreport.db' 58 59# The statuses are from `src/com/google/android/car/bugreport/Status.java. 60STATUS_WRITE_PENDING = 0 61STATUS_WRITE_FAILED = 1 62STATUS_UPLOAD_PENDING = 2 63STATUS_UPLOAD_SUCCESS = 3 64STATUS_UPLOAD_FAILED = 4 65STATUS_USER_CANCELLED = 5 66 67DUMPSTATE_DEADLINE_SEC = 300 # 10 minutes. 68UPLOAD_DEADLINE_SEC = 180 # 3 minutes. 69CHECK_STATUS_EVERY_SEC = 15 # Check status every 15 seconds. 70# Give BuigReport App 15 seconds to initialize after starting voice recording. 71META_BUGREPORT_WAIT_TIME_SEC = 15 72BUGREPORT_STATUS_POLL_TICK = 1 # Tick every 1 second 73 74# Regex to parse android build property lines from dumpstate (bugreport). 75PROP_LINE_RE = re.compile(r'^\[(.+)\]: \[(.+)\]$') 76 77# Holds bugreport info. See MetaBugReport.java. 78MetaBugReport = namedtuple( 79 'MetaBugReport', 80 ['id', 'timestamp', 'filepath', 'status', 'status_message']) 81 82# Holds a file from a zip file. 83# 84# Properties: 85# name : str - filename. 86# content : bytes - content of the file. 87# size : int - real size of the file. 88# compress_size : int - compressed size of the file. 89File = namedtuple('File', ['name', 'content', 'size', 'compress_size']) 90 91# Android Build Properties extract from dumpstate (bugreport) results. 92BuildProperties = namedtuple('BuildProperties', ['fingerprint']) 93 94 95def _red(msg): 96 return '\033[31m%s\033[0m' % msg 97 98 99def _green(msg): 100 return '\033[32m%s\033[0m' % msg 101 102 103def _fail_program(msg): 104 """Prints error message and exits the program.""" 105 print(_red(msg)) 106 exit(1) 107 108 109def _bugreport_status_to_str(status): 110 """Returns string representation of a bugreport status.""" 111 if status == STATUS_WRITE_PENDING: 112 return 'WRITE_PENDING' 113 elif status == STATUS_WRITE_FAILED: 114 return 'WRITE_FAILED' 115 elif status == STATUS_UPLOAD_PENDING: 116 return 'UPLOAD_PENDING' 117 elif status == STATUS_UPLOAD_SUCCESS: 118 return 'UPLOAD_SUCCESS' 119 elif status == STATUS_UPLOAD_FAILED: 120 return 'UPLOAD_FAILED' 121 elif status == STATUS_USER_CANCELLED: 122 return 'USER_CANCELLED' 123 return 'UNKNOWN_STATUS' 124 125 126class Device(object): 127 128 def __init__(self, serialno): 129 """Initializes BugreportAppTester. 130 131 Args: 132 serialno : Optional[str] - an android device serial number. 133 """ 134 self._serialno = serialno 135 136 def _read_lines_from_subprocess(self, popen): 137 """Reads lines from subprocess.Popen.""" 138 raw = popen.stdout.read() 139 try: 140 converted = str(raw, 'utf-8') 141 except TypeError: 142 converted = str(raw) 143 if not converted: 144 return [] 145 lines = re.split(r'\r?\n', converted) 146 return lines 147 148 def adb(self, cmd): 149 """Runs adb command on the device. 150 151 adb's stderr is redirected to this program's stderr. 152 153 Arguments: 154 cmd : List[str] - adb command and a list of arguments. 155 156 Returns: 157 Tuple[int, List[str]] - exit code and lines from the stdout of the 158 command. 159 """ 160 if self._serialno: 161 full_cmd = ['adb', '-s', self._serialno] + cmd 162 else: 163 full_cmd = ['adb'] + cmd 164 popen = subprocess.Popen(full_cmd, stdout=subprocess.PIPE) 165 stdout_lines = self._read_lines_from_subprocess(popen) 166 exit_code = popen.wait() 167 return (exit_code, stdout_lines) 168 169 def adbx(self, cmd): 170 """Runs adb command on the device, it fails the program is the cmd fails. 171 172 Arguments: 173 cmd : List[str] - adb command and a list of arguments. 174 175 Returns: 176 List[str] - lines from the stdout of the command. 177 """ 178 exit_code, stdout_lines = self.adb(cmd) 179 if exit_code != 0: 180 _fail_program('Failed to run command %s, exit_code=%s' % (cmd, exit_code)) 181 return stdout_lines 182 183 def is_adb_root(self): 184 """Checks if the adb is running as root.""" 185 return self.adb(['shell', 'ls', '/data/user/0'])[0] == 0 186 187 def restart_adb_as_root(self): 188 """Restarts adb as root.""" 189 if not self.is_adb_root(): 190 print("adb is not running as root. Running 'adb root'.") 191 self.adbx(['root']) 192 193 def pidof(self, package): 194 """Returns a list of PIDs for the package.""" 195 _, lines = self.adb(['shell', 'pidof', package]) 196 if not lines: 197 return None 198 pids_raw = [pid.strip() for pid in re.split(r'\s+', ' '.join(lines))] 199 return [int(pid) for pid in pids_raw if pid] 200 201 def disable_internet(self): 202 """Disables the Internet on the device.""" 203 print('\nDisabling the Internet.') 204 # NOTE: Need to run all these commands, otherwise sometimes airplane mode 205 # doesn't enabled. 206 self.adbx(['shell', 'svc', 'wifi', 'disable']) 207 self.adbx(['shell', 'settings', 'put', 'global', 'airplane_mode_on', '1']) 208 self.adbx([ 209 'shell', 'am', 'broadcast', '-a', 'android.intent.action.AIRPLANE_MODE', 210 '--ez', 'state', 'true' 211 ]) 212 213 def enable_internet(self): 214 """Enables the Internet on the device.""" 215 print('\nEnabling the Internet.') 216 self.adbx(['shell', 'settings', 'put', 'global', 'airplane_mode_on', '0']) 217 self.adbx([ 218 'shell', 'am', 'broadcast', '-a', 'android.intent.action.AIRPLANE_MODE', 219 '--ez', 'state', 'false' 220 ]) 221 self.adbx(['shell', 'svc', 'wifi', 'enable']) 222 223 224class BugreportAppTester(object): 225 226 def __init__(self, device): 227 """Initializes BugreportAppTester. 228 229 Args: 230 device : Device - an android device. 231 """ 232 self._device = device 233 234 def _kill_bugreport_app(self): 235 """Kills the BugReport App is it's running.""" 236 pids = self._device.pidof(BUGREPORT_PACKAGE) 237 if not pids: 238 return 239 for pid in pids: 240 print('Killing bugreport app with pid %d' % pid) 241 self._device.adb(['shell', 'kill', str(pid)]) 242 243 def _delete_all_bugreports(self): 244 """Deletes old zip files and bugreport entries in sqlite3.""" 245 print('Deleting old bugreports from the device...') 246 self._device.adb(['shell', 'rm', '-f', PENDING_BUGREPORTS_DIR + '/*.zip']) 247 self._device.adb( 248 ['shell', 'sqlite3', SQLITE_DB_PATH, '\'delete from bugreports;\'']) 249 250 def _start_bug_report(self): 251 """Starts BugReportActivity.""" 252 self._device.adbx( 253 ['shell', 'am', 'start', BUGREPORT_PACKAGE + '/.BugReportActivity']) 254 255 def _get_meta_bugreports(self): 256 """Returns bugreports from sqlite3 as a list of MetaBugReport.""" 257 tmpdir = tempfile.mkdtemp(prefix='aae-bugreport-', suffix='db') 258 exit_code, stdout_lines = self._device.adb(['pull', SQLITE_DB_DIR, tmpdir]) 259 if exit_code != 0: 260 shutil.rmtree(tmpdir, ignore_errors=True) 261 _fail_program('Failed to pull bugreport.db, msg=%s, exit_code=%s' % 262 (stdout_lines, exit_code)) 263 conn = sqlite3.connect(os.path.join(tmpdir, 'databases/bugreport.db')) 264 c = conn.cursor() 265 c.execute('select * from bugreports') 266 meta_bugreports = [] 267 # See BugStorageProvider.java for column indicies. 268 for row in c.fetchall(): 269 meta_bugreports.append( 270 MetaBugReport( 271 id=row[0], 272 timestamp=row[3], 273 filepath=row[5], 274 status=row[6], 275 status_message=row[7])) 276 conn.close() 277 shutil.rmtree(tmpdir, ignore_errors=True) 278 return meta_bugreports 279 280 def _get_active_bugreport(self): 281 """Returns current active MetaBugReport.""" 282 bugreports = self._get_meta_bugreports() 283 if len(bugreports) != 1: 284 _fail_program('Failure. Expected only 1 bugreport, but there are %d ' 285 'bugreports' % len(bugreports)) 286 return bugreports[0] 287 288 def _wait_for_bugreport_status_to_change_to(self, 289 expected_status, 290 deadline_sec, 291 bugreport_id, 292 allowed_statuses=[], 293 fail=False): 294 """Waits until status changes to expected_status. 295 296 Args: 297 expected_status : int - wait until status changes to this. 298 deadline_sec : float - how long to wait, fails if deadline reaches. 299 bugreport_id : int - bugreport to check. 300 allowed_statuses : List[int] - if the status changes to something else 301 than allowed_statuses, it fails. 302 fail : bool - exit the program if conditions don't meet. 303 304 Returns: 305 if succeeds it returns None. If fails it returns error message. 306 """ 307 timeout_at = time.time() + deadline_sec 308 last_fetch_at = time.time() 309 while time.time() < timeout_at: 310 remaining = timeout_at - time.time() 311 sys.stdout.write('Remaining time %.0f seconds\r' % remaining) 312 sys.stdout.flush() 313 time.sleep(BUGREPORT_STATUS_POLL_TICK) 314 if time.time() - last_fetch_at < CHECK_STATUS_EVERY_SEC: 315 continue 316 last_fetch_at = time.time() 317 bugreports = self._get_meta_bugreports() 318 meta_bugreport = next( 319 iter([b for b in bugreports if b.id == bugreport_id]), None) 320 if not meta_bugreport: 321 print() # new line to preserve the progress on terminal. 322 return 'Bugreport with id %d not found' % bugreport_id 323 if meta_bugreport.status in allowed_statuses: 324 # Expected, waiting for status to change. 325 pass 326 elif meta_bugreport.status == expected_status: 327 print() # new line to preserve the progress on terminal. 328 return None 329 else: 330 expected_str = _bugreport_status_to_str(expected_status) 331 actual_str = _bugreport_status_to_str(meta_bugreport.status) 332 print() # new line to preserve the progress on terminal. 333 return ('Expected status to be %s, but got %s. Message: %s' % 334 (expected_str, actual_str, meta_bugreport.status_message)) 335 print() # new line to preserve the progress on terminal. 336 return ('Timeout, status=%s' % 337 _bugreport_status_to_str(meta_bugreport.status)) 338 339 def _wait_for_bugreport_to_complete(self, bugreport_id): 340 """Waits until status changes to UPLOAD_PENDING. 341 342 It means dumpstate (bugreport) is completed (or failed). 343 344 Args: 345 bugreport_id : int - MetaBugReport id. 346 """ 347 print('\nWaiting until the bug report is collected.') 348 err_msg = self._wait_for_bugreport_status_to_change_to( 349 STATUS_UPLOAD_PENDING, 350 DUMPSTATE_DEADLINE_SEC, 351 bugreport_id, 352 allowed_statuses=[STATUS_WRITE_PENDING], 353 fail=True) 354 if err_msg: 355 _fail_program('Dumpstate (bugreport) failed: %s' % err_msg) 356 print('\nDumpstate (bugreport) completed (or failed).') 357 358 def _wait_for_bugreport_to_upload(self, bugreport_id): 359 """Waits bugreport to be uploaded and returns None if succeeds.""" 360 print('\nWaiting for the bug report to be uploaded.') 361 err_msg = self._wait_for_bugreport_status_to_change_to( 362 STATUS_UPLOAD_SUCCESS, 363 UPLOAD_DEADLINE_SEC, 364 bugreport_id, 365 allowed_statuses=[STATUS_UPLOAD_PENDING]) 366 if err_msg: 367 print('Failed to upload: %s' % err_msg) 368 return err_msg 369 print('\nBugreport was successfully uploaded.') 370 return None 371 372 def _extract_important_files(self, local_zippath): 373 """Extracts txt, jpg, png and 3gp files from the zip file.""" 374 files = [] 375 with zipfile.ZipFile(local_zippath) as zipf: 376 for info in zipf.infolist(): 377 file_ext = info.filename.split('.')[-1] 378 if file_ext in ['txt', 'jpg', 'png', '3gp']: 379 files.append( 380 File( 381 name=info.filename, 382 content=zipf.read(info.filename), 383 size=info.file_size, 384 compress_size=info.compress_size)) 385 return files 386 387 def _is_image(self, file): 388 """Returns True if the file is an image.""" 389 ext = file.name.split('.')[-1] 390 return ext in ['png', 'jpg'] 391 392 def _validate_image(self, file): 393 if file.compress_size == 0: 394 return _red('[Invalid] Image %s is empty.' % file.name) 395 return file.name + ' (%d kb)' % (file.compress_size / 1024) 396 397 def _is_audio(self, file): 398 """Returns True if the file is an audio.""" 399 return file.name.endswith('.3gp') 400 401 def _validate_audio(self, file): 402 """If valid returns (True, msg), otherwise returns (False, msg).""" 403 if file.compress_size == 0: 404 return _red('[Invalid] Audio %s is empty' % file.name) 405 return file.name + ' (%d kb)' % (file.compress_size / 1024) 406 407 def _is_dumpstate(self, file): 408 """Returns True if the file is a dumpstate (bugreport) results.""" 409 if not file.name.endswith('.txt'): 410 return None # Just ignore. 411 content = file.content.decode('ascii', 'ignore') 412 return '== dumpstate:' in content 413 414 def _parse_dumpstate(self, file): 415 """Parses dumpstate file and returns BuildProperties.""" 416 properties = {} 417 lines = file.content.decode('ascii', 'ignore').split('\n') 418 for line in lines: 419 match = PROP_LINE_RE.match(line.strip()) 420 if match: 421 prop, value = match.group(1), match.group(2) 422 properties[prop] = value 423 return BuildProperties(fingerprint=properties['ro.build.fingerprint']) 424 425 def _validate_dumpstate(self, file, build_properties): 426 """If valid returns (True, msg), otherwise returns (False, msg).""" 427 if file.compress_size < 100 * 1024: # suspicious if less than 100 kb 428 return _red('[Invalid] Suspicious dumpstate: %s, size: %d bytes' % 429 (file.name, file.compress_size)) 430 if not build_properties.fingerprint: 431 return _red('[Invalid] Strange dumpstate without fingerprint: %s' % 432 file.name) 433 return file.name + ' (%.2f mb)' % (file.compress_size / 1024.0 / 1024.0) 434 435 def _validate_files(self, files, local_zippath, meta_bugreport): 436 """Validates files extracted from zip file and returns validation result. 437 438 Arguments: 439 files : List[File] - list of files extracted from bugreport zip file. 440 local_zippath : str - bugreport zip file path. 441 meta_bugreport : MetaBugReport - a subject bug report. 442 443 Returns: 444 List[str] - a validation result that can be printed. 445 """ 446 images = [] 447 dumpstates = [] 448 audios = [] 449 build_properties = BuildProperties(fingerprint='') 450 for file in files: 451 if self._is_image(file): 452 images.append(self._validate_image(file)) 453 elif self._is_audio(file): 454 audios.append(self._validate_audio(file)) 455 elif self._is_dumpstate(file): 456 build_properties = self._parse_dumpstate(file) 457 dumpstates.append(self._validate_dumpstate(file, build_properties)) 458 459 result = [] 460 zipfilesize = os.stat(local_zippath).st_size 461 result.append('Zip file: %s (%.2f mb)' % (os.path.basename( 462 meta_bugreport.filepath), zipfilesize / 1024.0 / 1024.0)) 463 result.append('Fingerprint: %s\n' % build_properties.fingerprint) 464 result.append('Images count: %d ' % len(images)) 465 for img_validation in images: 466 result.append(' - %s' % img_validation) 467 result.append('\nAudio count: %d ' % len(audios)) 468 for audio_validation in audios: 469 result.append(' - %s' % audio_validation) 470 result.append('\nDumpstate (bugreport) count: %d ' % len(dumpstates)) 471 for dumpstate_validation in dumpstates: 472 result.append(' - %s' % dumpstate_validation) 473 return result 474 475 def _write_files_to_data_dir(self, files, data_dir): 476 """Writes files to data_dir.""" 477 for file in files: 478 if (not (self._is_image(file) or self._is_audio(file) or 479 self._is_dumpstate(file))): 480 continue 481 with open(os.path.join(data_dir, file.name), 'wb') as wfile: 482 wfile.write(file.content) 483 print('Files have been written to %s' % data_dir) 484 485 def _process_bugreport(self, meta_bugreport): 486 """Checks zip file contents, returns validation results. 487 488 Arguments: 489 meta_bugreport : MetaBugReport - a subject bugreport. 490 491 Returns: 492 List[str] - validation results. 493 """ 494 print('Processing bugreport id=%s, timestamp=%s' % 495 (meta_bugreport.id, meta_bugreport.timestamp)) 496 tmpdir = tempfile.mkdtemp(prefix='aae-bugreport-', suffix='zip', dir=".") 497 zippath = tmpdir + '/bugreport.zip' 498 exit_code, stdout_lines = self._device.adb( 499 ['pull', meta_bugreport.filepath, zippath]) 500 if exit_code != 0: 501 print('\n'.join(stdout_lines)) 502 shutil.rmtree(tmpdir, ignore_errors=True) 503 _fail_program('Failed to pull bugreport zip file, exit_code=%s' % 504 exit_code) 505 print('Zip file saved to %s' % zippath) 506 507 files = self._extract_important_files(zippath) 508 results = self._validate_files(files, zippath, meta_bugreport) 509 510 self._write_files_to_data_dir(files, tmpdir) 511 512 return results 513 514 def run(self): 515 """Runs BugreportAppTester.""" 516 self._device.restart_adb_as_root() 517 518 if self._device.pidof('dumpstate'): 519 _fail_program('\nFailure. dumpstate binary is already running.') 520 521 self._device.disable_internet() 522 self._kill_bugreport_app() 523 self._delete_all_bugreports() 524 525 # Start BugReport App; it starts recording audio. 526 self._start_bug_report() 527 print('\n\n') 528 print(_green('************** MANUAL **************')) 529 print( 530 'Please speak something to the device\'s microphone.\n' 531 'After that press *Submit* button and wait until the script finishes.\n' 532 ) 533 time.sleep(META_BUGREPORT_WAIT_TIME_SEC) 534 meta_bugreport = self._get_active_bugreport() 535 536 self._wait_for_bugreport_to_complete(meta_bugreport.id) 537 538 check_results = self._process_bugreport(meta_bugreport) 539 540 self._device.enable_internet() 541 542 err_msg = self._wait_for_bugreport_to_upload(meta_bugreport.id) 543 if err_msg: 544 check_results += [ 545 _red('\nUpload failed, make sure the device has ' 546 'Internet: ' + err_msg) 547 ] 548 else: 549 check_results += ['\nUpload succeeded.'] 550 551 print('\n\n') 552 print(_green('************** FINAL RESULTS *********************')) 553 print('%s v%s' % (os.path.basename(__file__), VERSION)) 554 555 print('\n'.join(check_results)) 556 print() 557 print('Please verify the contents of files.') 558 559 560def main(): 561 parser = argparse.ArgumentParser(description='BugReport App Tester.') 562 parser.add_argument( 563 '-s', metavar='SERIAL', type=str, help='use device with given serial.') 564 565 args = parser.parse_args() 566 567 device = Device(serialno=args.s) 568 BugreportAppTester(device).run() 569 570 571if __name__ == '__main__': 572 main() 573