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