taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

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()