| 1 | #!/usr/bin/env python3 |
| 2 | """Checks that an edit outlives a force-stop and an offline cold start, on a disposable emulator |
| 3 | the caller starts and stops: |
| 4 | |
| 5 | python3 apps/android/check.py --adb ADB --serial emulator-5560 --apk Snowbound.apk --output DIR |
| 6 | |
| 7 | It types a token into the page, waits until the section reports it saved, force-stops the app, |
| 8 | turns the network off, starts it again and screenshots the page, then reads the section file |
| 9 | back with onestore's inspect example and looks for the token in it. The page must have drawn |
| 10 | where the keyboard was, which GL on Android 8 once left black. |
| 11 | """ |
| 12 | import argparse |
| 13 | import json |
| 14 | from pathlib import Path |
| 15 | import secrets |
| 16 | import struct |
| 17 | import subprocess |
| 18 | import time |
| 19 | |
| 20 | ROOT = Path(__file__).resolve().parents[2] |
| 21 | PACKAGE = 'net.paperclover.snowbound' |
| 22 | SECTION = 'files/Notebooks/Snowbound/New Section 1.one' |
| 23 | |
| 24 | |
| 25 | def main(): |
| 26 | parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) |
| 27 | parser.add_argument('--adb', type=Path, required=True) |
| 28 | parser.add_argument('--serial', required=True) |
| 29 | parser.add_argument('--apk', type=Path, required=True) |
| 30 | parser.add_argument('--output', type=Path, required=True) |
| 31 | args = parser.parse_args() |
| 32 | output = args.output.resolve() |
| 33 | output.mkdir(parents=True, exist_ok=False) |
| 34 | |
| 35 | def adb(*command, binary=False, timeout=60): |
| 36 | result = subprocess.run([str(args.adb), '-s', args.serial, *map(str, command)], capture_output=True, |
| 37 | check=True, timeout=timeout) |
| 38 | return result.stdout if binary else result.stdout.decode() |
| 39 | |
| 40 | def logged(after, line, timeout=60): |
| 41 | """Waits for `line` in the app's log after the last `after`, if any.""" |
| 42 | deadline = time.monotonic() + timeout |
| 43 | while time.monotonic() < deadline: |
| 44 | lines = adb('logcat', '-d', '-s', 'snowbound:I').splitlines() |
| 45 | marks = [index for index, text in enumerate(lines) if text.endswith(after)] if after else [-1] |
| 46 | if marks and any(text.endswith(line) for text in lines[marks[-1] + 1:]): |
| 47 | return |
| 48 | time.sleep(0.5) |
| 49 | raise TimeoutError(f'No "{line}" in the log') |
| 50 | |
| 51 | def launch(): |
| 52 | adb('logcat', '-c') |
| 53 | adb('shell', 'am', 'start', '-W', '-n', f'{PACKAGE}/.PageActivity') |
| 54 | logged(None, 'Page view created') |
| 55 | time.sleep(2) |
| 56 | |
| 57 | if adb('shell', 'getprop', 'ro.kernel.qemu').strip() != '1': |
| 58 | raise SystemExit('This check runs only on an emulator') |
| 59 | token = 'check' + secrets.token_hex(4) |
| 60 | width, height = map(int, adb('shell', 'wm', 'size').split()[-1].split('x')) |
| 61 | adb('install', '-r', args.apk.resolve(), timeout=180) |
| 62 | adb('shell', 'am', 'force-stop', PACKAGE) |
| 63 | try: |
| 64 | launch() |
| 65 | adb('shell', 'input', 'tap', width // 4, height * 3 // 10) |
| 66 | time.sleep(1) |
| 67 | adb('shell', 'input', 'text', token) |
| 68 | adb('shell', 'input', 'keyevent', 'KEYCODE_ENTER') |
| 69 | logged('Section status 2', 'Section status 1') |
| 70 | (output / 'typed.png').write_bytes(adb('exec-out', 'screencap', '-p', binary=True)) |
| 71 | adb('shell', 'am', 'force-stop', PACKAGE) |
| 72 | adb('shell', 'cmd', 'connectivity', 'airplane-mode', 'enable') |
| 73 | launch() |
| 74 | (output / 'reopened.png').write_bytes(adb('exec-out', 'screencap', '-p', binary=True)) |
| 75 | # A raw capture: width and height, then RGBA rows after a header whose size varies by release. |
| 76 | screen = adb('exec-out', 'screencap', binary=True) |
| 77 | columns, rows = struct.unpack_from('<2I', screen) |
| 78 | pixel = len(screen) - columns * rows * 4 + (rows * 3 // 4 * columns + columns // 2) * 4 |
| 79 | drawn = any(screen[pixel:pixel + 3]) |
| 80 | section = output / 'section.one' |
| 81 | section.write_bytes(adb('exec-out', 'run-as', PACKAGE, 'cat', SECTION, binary=True)) |
| 82 | (output / 'logcat.txt').write_text(adb('logcat', '-d')) |
| 83 | finally: |
| 84 | adb('shell', 'cmd', 'connectivity', 'airplane-mode', 'disable') |
| 85 | inspected = subprocess.run(['cargo', 'run', '-q', '-p', 'onestore', '--example', 'inspect', '--', section], |
| 86 | cwd=ROOT, capture_output=True, text=True, check=True).stdout.strip() |
| 87 | data = section.read_bytes() |
| 88 | result = { |
| 89 | 'token': token, |
| 90 | 'inspect': inspected, |
| 91 | 'intact': inspected.endswith(' 0 checksum mismatches'), |
| 92 | # Text is stored as UTF-16, or as single bytes where it fits them. |
| 93 | 'tokenInSection': token.encode('utf-16-le') in data or token.encode() in data, |
| 94 | 'drawn': drawn, |
| 95 | } |
| 96 | (output / 'result.json').write_text(json.dumps(result, indent=2) + '\n') |
| 97 | print(json.dumps(result, indent=2)) |
| 98 | if not (result['intact'] and result['tokenInSection']): |
| 99 | raise SystemExit('The edit did not survive') |
| 100 | if not drawn: |
| 101 | raise SystemExit('The reopened page left the screen black') |
| 102 | |
| 103 | |
| 104 | if __name__ == '__main__': |
| 105 | main() |