capture-screenshots.py (11182B)
1 #!/usr/bin/env python3 2 """Capture POS screenshot subsets and optionally apply the Play Store templates. 3 4 Requires an installed debug APK on an explicitly selected emulator, adb and Pillow. 5 Outputs images only. Use --plan to inspect the selected captures without a device. 6 """ 7 import argparse 8 from dataclasses import dataclass 9 import io 10 import json 11 from pathlib import Path 12 import shutil 13 import subprocess 14 import sys 15 import time 16 17 from PIL import Image, ImageStat 18 19 ROOT = Path(__file__).resolve().parents[1] 20 DESIGN = ROOT / 'design/google-play' 21 APP = 'net.taler.merchantpos' 22 # Keep this order stable: tutorial filenames are referenced by existing documents. 23 SCENARIOS = [ 24 'login', 25 'mfa-select', 26 'mfa-code', 27 'camera-rationale', 28 'camera-blocked', 29 'navigation', 30 'order', 31 'quantity-before', 32 'quantity-after', 33 'order-cleared', 34 'order-custom', 35 'order-custom-added', 36 'amount-entry', 37 'checkout', 38 'checkout-cash-entry', 39 'checkout-card-entry', 40 'checkout-partial', 41 'checkout-payments', 42 'checkout-settled', 43 'checkout-noreader', 44 'payment', 45 'payment-claimed', 46 'payment-external', 47 'payment-success', 48 'history', 49 'history-mixed', 50 'history-external-refund', 51 'order-details', 52 'order-details-partial', 53 'order-details-minimal', 54 'order-details-loading', 55 'order-details-error', 56 'refund', 57 'refund-mixed', 58 'refund-qr', 59 'settings', 60 'settings-logout', 61 'payment-options', 62 'payment-cash', 63 'payment-card', 64 'displays-empty', 65 'displays-add', 66 'displays-access', 67 'displays-settings', 68 'displays-pair', 69 'displays-find', 70 'displays-pairing', 71 'displays-ready', 72 'displays-connected', 73 'displays-test', 74 'displays-disconnected', 75 'displays-remove', 76 'displays-usb', 77 'displays-repair-android', 78 'displays-repair-lcd', 79 'order-details-products', 80 'settings-about', 81 'order-catalogue-more', 82 ] 83 SCROLLED = { 84 'order-details-products': 'order-details', 85 'settings-about': 'settings', 86 'order-catalogue-more': 'order', 87 } 88 # wm size, density, rotation, expected PNG size 89 PROFILES = { 90 'tutorials': { 91 'tablet-landscape': ('1600x2560', '320', '1', (2560, 1600)), 92 'phone-portrait': ('1080x2340', '420', '0', (1080, 2340)), 93 }, 94 'play-store': { 95 'tablet-landscape': ('1080x1920', '240', '1', (1920, 1080)), 96 'phone-portrait': ('1080x2400', '420', '0', (1080, 2400)), 97 }, 98 } 99 100 101 @dataclass(frozen=True) 102 class Capture: 103 subset: str 104 profile: str 105 locale: str 106 theme: str 107 scenario: str 108 path: Path 109 card: str | None = None 110 111 112 def plan(args, cards): 113 jobs = [] 114 subsets = list(PROFILES) if args.subset == 'all' else [args.subset] 115 for subset in subsets: 116 for profile in args.profiles or PROFILES[subset]: 117 if subset == 'tutorials': 118 choices = [(None, f'{i:02d}-{s}.png', s, theme) 119 for i, s in enumerate(SCENARIOS, 1) 120 for theme in args.themes or ['light', 'dark']] 121 else: 122 choices = [(name, card['screenshot'], Path(card['screenshot']).stem.split('-', 1)[1], card['theme']) 123 for name, card in cards.items()] 124 for locale in args.locales: 125 for card, filename, scenario, theme in choices: 126 if args.scenarios and scenario not in args.scenarios: 127 continue 128 if args.themes and theme not in args.themes: 129 continue 130 base = args.out / subset 131 if subset == 'play-store': 132 base = args.from_captures or base / 'captures' 133 jobs.append(Capture(subset, profile, locale, theme, scenario, 134 base / locale / profile / theme / filename, card)) 135 return jobs 136 137 138 def check_image(data, expected, label): 139 with Image.open(io.BytesIO(data)) as original: 140 image = original.convert('RGB') 141 if image.size != expected: 142 raise RuntimeError(f'Wrong screenshot size for {label}: {image.size}, expected {expected}') 143 if max(ImageStat.Stat(image).stddev) < 3: 144 raise RuntimeError(f'Blank screenshot: {label}') 145 146 147 def capture(jobs, args): 148 def adb(*cmd, binary=False): 149 return subprocess.check_output([args.adb, '-s', args.serial, *cmd], timeout=45, text=not binary) 150 151 saved = {key: adb('shell', 'settings', 'get', 'system', key).strip() 152 for key in ['accelerometer_rotation', 'user_rotation', 'font_scale']} 153 originals = {key: adb('shell', 'wm', key) for key in ['size', 'density']} 154 active = None 155 try: 156 adb('shell', 'settings', 'put', 'system', 'accelerometer_rotation', '0') 157 adb('shell', 'settings', 'put', 'system', 'font_scale', '1.0') 158 for job in jobs: 159 size, density, rotation, expected = PROFILES[job.subset][job.profile] 160 if args.resume and job.path.exists(): 161 check_image(job.path.read_bytes(), expected, job.path) 162 continue 163 if active != (job.subset, job.profile): 164 adb('shell', 'am', 'force-stop', APP) 165 adb('shell', 'wm', 'size', size) 166 adb('shell', 'wm', 'density', density) 167 adb('shell', 'settings', 'put', 'system', 'user_rotation', rotation) 168 active = (job.subset, job.profile) 169 scenario = SCROLLED.get(job.scenario, job.scenario) 170 adb('shell', 'am', 'force-stop', APP) 171 adb('logcat', '-c') 172 adb('shell', 'am', 'start', '-W', '-n', APP + '/.debug.ScreenshotActivity', 173 '--es', 'screenshot_scenario', scenario, '--es', 'screenshot_locale', job.locale, 174 '--ez', 'screenshot_dark', str(job.theme == 'dark').lower()) 175 # Activity launch can restore a previous rotation on Android 17. 176 # Apply the capture orientation once the activity is foregrounded. 177 adb('shell', 'wm', 'user-rotation', 'lock', rotation) 178 deadline = time.monotonic() + 25 179 while True: 180 log = adb('logcat', '-d', '-s', 'SCREENSHOT_READY:I', '*:S') 181 if any(line.rstrip().endswith(': ' + scenario) for line in log.splitlines()): 182 break 183 if time.monotonic() > deadline: 184 raise RuntimeError(f'No ready signal: {job.path}') 185 time.sleep(.2) 186 time.sleep(8 if scenario in ('payment', 'payment-external', 'refund-qr') else 1.5) 187 if job.scenario in SCROLLED: 188 w, h = expected 189 start_y = .5 if job.scenario == 'order-catalogue-more' else .65 190 adb('shell', 'input', 'swipe', str(w // 2), str(int(h * start_y)), 191 str(w // 2), str(int(h * .18)), '700') 192 time.sleep(1) 193 png = adb('exec-out', 'screencap', '-p', binary=True) 194 check_image(png, expected, job.path) 195 job.path.parent.mkdir(parents=True, exist_ok=True) 196 job.path.write_bytes(png) 197 print(f'Captured {job.path}', flush=True) 198 finally: 199 # Attempt every restore even if a disconnected device makes one fail. 200 commands = [('shell', 'am', 'force-stop', APP)] 201 for command, original in originals.items(): 202 override = next((line.split(':', 1)[1].strip() for line in original.splitlines() 203 if line.startswith('Override')), 'reset') 204 commands.append(('shell', 'wm', command, override)) 205 for key, value in saved.items(): 206 commands.append(('shell', 'settings', 'delete' if value == 'null' else 'put', 'system', key, 207 *([] if value == 'null' else [value]))) 208 errors = [] 209 for command in commands: 210 try: 211 adb(*command) 212 except (OSError, subprocess.SubprocessError) as error: 213 errors.append(error) 214 print(f'Could not restore emulator setting: {command}: {error}', file=sys.stderr) 215 if errors and sys.exc_info()[0] is None: 216 raise RuntimeError('Could not restore all emulator settings') from errors[0] 217 218 219 def render(jobs, args): 220 selected = [job for job in jobs if job.subset == 'play-store'] 221 # Fail before generating a partial set when reusing incomplete or wrong-size captures. 222 for job in selected: 223 check_image(job.path.read_bytes(), PROFILES[job.subset][job.profile][3], job.path) 224 for job in selected: 225 profile = 'phone' if job.profile == 'phone-portrait' else 'tablet' 226 subprocess.run([sys.executable, str(DESIGN / 'render.py'), '--screenshot', str(job.path), 227 '--profile', profile, '--card', job.card, '--png-only', 228 '--out', str(args.out / 'play-store')], check=True) 229 230 231 def main(): 232 parser = argparse.ArgumentParser(description=__doc__) 233 parser.add_argument('--set', dest='subset', choices=[*PROFILES, 'all'], default='tutorials') 234 parser.add_argument('--serial', help='Dedicated emulator serial (required for new captures)') 235 parser.add_argument('--adb', default='adb') 236 parser.add_argument('--out', type=Path, default=ROOT / 'build/screenshots') 237 parser.add_argument('--profiles', nargs='+', choices=PROFILES['tutorials']) 238 parser.add_argument('--themes', nargs='+', choices=['light', 'dark']) 239 parser.add_argument('--locales', nargs='+', choices=['en', 'de', 'fr'], default=['en']) 240 parser.add_argument('--scenarios', nargs='+', choices=SCENARIOS) 241 parser.add_argument('--resume', action='store_true', help='Validate and reuse existing captures') 242 parser.add_argument('--from-captures', type=Path, help='For play-store: render an existing capture root without adb') 243 parser.add_argument('--plan', action='store_true', help='Print selected captures without writing files or using adb') 244 args = parser.parse_args() 245 if args.from_captures and args.subset != 'play-store': 246 parser.error('--from-captures requires --set play-store') 247 if args.subset in ('play-store', 'all') and args.locales != ['en']: 248 parser.error('Play Store headlines currently support English only; use --locales en') 249 cards = json.loads((DESIGN / 'cards.json').read_text()) 250 jobs = plan(args, cards) 251 if not jobs: 252 parser.error('No screenshots match the selected subset and filters') 253 if args.plan: 254 for job in jobs: 255 print(f'{job.subset}: {job.profile}/{job.locale}/{job.theme}/{job.scenario} → {job.path}') 256 print(f'{len(jobs)} captures') 257 return 258 if any(job.card for job in jobs) and not shutil.which('rsvg-convert'): 259 parser.error('Play Store templates require rsvg-convert (librsvg) on PATH') 260 if not args.from_captures: 261 if not args.serial or not args.serial.startswith('emulator-'): 262 parser.error('Select a dedicated emulator with --serial emulator-NNNN') 263 capture(jobs, args) 264 render(jobs, args) 265 266 267 if __name__ == '__main__': 268 main()