• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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