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