Files

305 lines
12 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Cold-launch an app and capture a time line of screenshots plus logcat signals.
WHY THIS EXISTS
---------------
"Did the patch work?" for anything user-visible -- a splash/ad gate, a forced
update dialog, a login wall, a crash-on-start -- is answered by looking at the
screen over the first seconds of a launch, and by comparison against the
unmodified build. Doing that by hand produces two failures that look like
findings and are not:
* **Sampling is not observation.** A dialog that appears and is then occluded
can fall entirely between samples, and every frame you happened to take shows
something else, which feels like corroboration. This tool captures on a fixed
short cadence and, crucially, prints what it captured so a human or agent
actually LOOKS at the frames.
* **`am start -W` can block past a naive timeout.** It waits for the first
frame; on some ROMs that never arrives while another window owns the display
and the command simply hangs. This tool launches without `-W` and derives the
time line from captures plus logcat instead.
It also records the two facts that decide whether an install even happened:
the installed version, and the on-disk APK hash. "Install succeeded" describes
the request, not the app on disk.
IMPORTANT -- before trusting any screenshot, confirm the foreground activity is
your target. A vendor package installer left on screen (a pending confirmation
from an earlier `adb install`, for example) will be captured instead of your app
and looks completely plausible. `--expect-activity` makes that check automatic.
Usage
-----
python coldstart.py --serial <SERIAL> --pkg com.example \\
--activity com.example/.MainActivity --duration 14 --interval 0.8 \\
--out work/verify/orig --expect-activity com.example/.MainActivity
Requires: adb. `su` on the device is used when available for version/hash facts,
otherwise it degrades to plain `pm`/`dumpsys`.
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
import time
def run(cmd, timeout=30, serial=None):
"""Run a command with a hard timeout; never let a call stall the run."""
try:
return subprocess.run(cmd, capture_output=True, timeout=timeout,
text=True, errors='replace')
except subprocess.TimeoutExpired:
return subprocess.CompletedProcess(cmd, 124, '', 'timeout after %ss' % timeout)
class Device(object):
def __init__(self, serial, use_su=True, timeout=30):
self.serial = serial
self.timeout = timeout
self.adb = shutil.which('adb') or 'adb'
self.su = use_su and self._su_works()
def _su_works(self):
if self.serial:
r = run(['adb', '-s', self.serial, 'shell', "su -c 'id'"], 15)
else:
r = run(['adb', 'shell', "su -c 'id'"], 15)
return r.returncode == 0 and 'uid=0' in (r.stdout or '')
def shell(self, cmd, timeout=None):
"""Run a device shell command, preferring root when available."""
t = timeout or self.timeout
if self.serial:
base = ['adb', '-s', self.serial, 'shell']
else:
base = ['adb', 'shell']
if self.su:
return run(base + ["su -c '%s'" % cmd.replace("'", "'\\''")], t)
return run(base + [cmd], t)
def screencap(self, path, timeout=30):
"""Capture one PNG. Returns bytes written, or -1 on failure."""
if self.serial:
base = ['adb', '-s', self.serial]
else:
base = ['adb']
try:
with open(path, 'wb') as fh:
r = subprocess.run(base + ['exec-out', 'screencap', '-p'],
stdout=fh, timeout=timeout)
size = os.path.getsize(path)
if size < 8:
return -1
with open(path, 'rb') as fh:
if fh.read(8) != b'\x89PNG\r\n\x1a\n':
return -1
return size
except subprocess.TimeoutExpired:
return -1
def foreground_activity(dev):
r = dev.shell('dumpsys activity activities | grep -m1 ResumedActivity')
m = re.search(r'([A-Za-z0-9_.]+)/([A-Za-z0-9_.$]+)', r.stdout or '')
return '%s/%s' % (m.group(1), m.group(2)) if m else None
def installed_facts(dev, pkg):
facts = {}
r = dev.shell("dumpsys package %s | grep -E 'versionCode|versionName|firstInstallTime|lastUpdateTime'" % pkg)
for line in (r.stdout or '').splitlines():
m = re.match(r'\s*(versionCode|versionName|firstInstallTime|lastUpdateTime)=(.*)', line)
if m:
facts[m.group(1)] = m.group(2).strip()
r = dev.shell('ls /data/app/*/%s*/base.apk 2>/dev/null' % pkg)
apk_path = (r.stdout or '').strip().splitlines()[:1]
if apk_path:
facts['apk_path'] = apk_path[0]
r2 = dev.shell('sha256sum %s' % apk_path[0])
m = re.search(r'([0-9a-f]{64})', r2.stdout or '')
if m:
facts['apk_sha256'] = m.group(1)
r = dev.shell('dumpsys package %s | grep -m1 userId=' % pkg)
m = re.search(r'userId=(\d+)', r.stdout or '')
if m:
facts['uid'] = m.group(1)
return facts
def signal_counts(log_path, patterns):
"""Count occurrences of each pattern in a logcat dump."""
try:
with open(log_path, encoding='utf-8', errors='replace') as fh:
text = fh.read()
except OSError:
return {}
return {name: len(re.findall(rx, text)) for name, rx in patterns.items()}
SIGNAL_PATTERNS = {
'FATAL EXCEPTION': r'FATAL EXCEPTION',
'VerifyError': r'VerifyError',
'Bad checksum': r'Bad checksum',
'IncompatibleClassChangeError': r'IncompatibleClassChangeError',
'ClassNotFoundException': r'ClassNotFoundException',
'uncaughtException': r'uncaughtException',
'am_crash': r'am_crash',
'ANR': r'ANR in ',
'Displayed': r'Displayed .*\+([0-9]+)ms',
}
def main(argv):
ap = argparse.ArgumentParser(
description='Cold-launch an app and capture a screenshot time line plus '
'logcat signals, for before/after comparison.',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__.split('Usage')[-1][:1200])
ap.add_argument('--serial', help='adb serial (REQUIRED when several devices are '
'online, so the wrong one is not picked at random)')
ap.add_argument('--pkg', required=True, help='package name')
ap.add_argument('--activity', help='component to start (default: monkey/launcher)')
ap.add_argument('--out', default='coldstart', help='output directory')
ap.add_argument('--duration', type=float, default=14.0, help='seconds to capture')
ap.add_argument('--interval', type=float, default=0.8, help='seconds between frames')
ap.add_argument('--expect-activity', help='warn if the foreground activity is not '
'this (and not the package at all)')
ap.add_argument('--no-su', action='store_true', help='never use device root')
ap.add_argument('--keep-old', action='store_true',
help='do not clear existing screenshots in the output dir')
args = ap.parse_args(argv[1:])
if not shutil.which('adb'):
print('error: adb not found on PATH')
return 2
if not args.serial:
r = run(['adb', 'devices'])
online = [l.split()[0] for l in (r.stdout or '').splitlines()[1:]
if len(l.split()) >= 2 and l.split()[1] == 'device']
if len(online) > 1:
print('error: %d devices online (%s) and no --serial given. adb would '
'pick one at random and you would debug the wrong target.'
% (len(online), ', '.join(online)))
return 2
if online:
args.serial = online[0]
print('[i] single device online; using %s' % args.serial)
dev = Device(args.serial, use_su=not args.no_su)
print('[i] serial=%s root=%s' % (args.serial, dev.su))
os.makedirs(args.out, exist_ok=True)
shots = os.path.join(args.out, 'shots')
os.makedirs(shots, exist_ok=True)
if not args.keep_old:
for f in os.listdir(shots):
if f.endswith('.png'):
os.remove(os.path.join(shots, f))
facts = installed_facts(dev, args.pkg)
print('\n== installed build facts')
for k in sorted(facts):
print(' %-18s %s' % (k, facts[k]))
if not facts.get('versionCode'):
print(' WARNING: no versionCode found -- the package may not be installed')
print(' (install success describes the request, not the app on disk; the '
'sha256 above is the on-disk truth)')
print('\n== clearing logcat')
dev.shell('logcat -c', timeout=20)
print('== force-stopping %s' % args.pkg)
dev.shell('am force-stop %s' % args.pkg, timeout=20)
print('== launching')
t0 = time.time()
if args.activity:
r = dev.shell('am start -n %s' % args.activity, timeout=25)
else:
r = dev.shell('monkey -p %s -c android.intent.category.LAUNCHER 1' % args.pkg,
timeout=25)
out = ((r.stdout or '') + (r.stderr or '')).strip()
print(' %s' % (out.splitlines()[0] if out else '(no output)'))
if 'Error' in out or 'Exception' in out:
print(' LAUNCH ERROR -- read the line above before blaming the patch')
# live logcat into a file while we capture
log_path = os.path.join(args.out, 'logcat.txt')
if args.serial:
log_cmd = ['adb', '-s', args.serial, 'logcat', '-v', 'threadtime']
else:
log_cmd = ['adb', 'logcat', '-v', 'threadtime']
log_fh = open(log_path, 'w', encoding='utf-8', errors='replace')
log_proc = subprocess.Popen(log_cmd, stdout=log_fh, stderr=subprocess.DEVNULL)
frames = []
idx = 0
try:
while True:
el = time.time() - t0
if el > args.duration:
break
name = 'f%02d_%05.2fs.png' % (idx, el)
path = os.path.join(shots, name)
n = dev.screencap(path)
frames.append((name, n, el))
print(' %-20s %s' % (name, ('%d bytes' % n) if n > 0 else 'CAPTURE FAILED'))
idx += 1
time.sleep(max(0.0, args.interval))
finally:
time.sleep(0.5)
log_proc.terminate()
try:
log_proc.wait(timeout=10)
except subprocess.TimeoutExpired:
log_proc.kill()
log_fh.close()
fg = foreground_activity(dev)
print('\n== foreground at end: %s' % fg)
if args.expect_activity and fg and args.pkg not in fg:
print(' *** FOREGROUND IS NOT YOUR APP (%s) ***' % fg)
print(' Everything captured above shows something else -- most often a '
'vendor package-installer confirmation left over from an install.')
print(' Clear it (e.g. close the installer, press HOME) and re-run; do not '
'read these frames as evidence about your build.')
counts = signal_counts(log_path, SIGNAL_PATTERNS)
print('\n== logcat signals (%s)' % log_path)
for name in SIGNAL_PATTERNS:
print(' %-32s %s' % (name, counts.get(name, 0)))
m = re.findall(r'Displayed ([^:]+): \+([0-9]+)ms', open(log_path, encoding='utf-8',
errors='replace').read())
if m:
print('\n== launch timing (logcat Displayed)')
for comp, ms in m:
print(' %s %s ms' % (comp.strip(), ms))
else:
print('\n no "Displayed" line: the launch may not have reached a first '
'frame (a hang or a blocking dialog looks exactly like this)')
zero = sum(1 for _n, n, _e in frames if n <= 0)
print('\n== %d frame(s) in %s, %d failed' % (len(frames), shots, zero))
# the point of the tool: make inspection happen
print('\n== INSPECT THE FRAMES. A capture nobody looked at is not evidence.')
nonzero = [f for f in sorted(os.listdir(shots)) if f.endswith('.png')]
if nonzero:
print(' start with: %s' % os.path.join(shots, nonzero[0]))
mid = nonzero[len(nonzero) // 2]
print(' and: %s' % os.path.join(shots, mid))
print(' Byte-identical consecutive frames mean the screen is static -- that '
'is a finding (a hang, or a dialog waiting), not a capture problem.')
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv))