1#!/usr/bin/env python3
2"""Checks that an edit outlives a force-stop and an offline cold start, on a disposable emulator
3the caller starts and stops:
4
5 python3 apps/android/check.py --adb ADB --serial emulator-5560 --apk Snowbound.apk --output DIR
6
7It types a token into the page, waits until the section reports it saved, force-stops the app,
8turns the network off, starts it again and screenshots the page, then reads the section file
9back with onestore's inspect example and looks for the token in it. The page must have drawn
10where the keyboard was, which GL on Android 8 once left black.
11"""
12import argparse
13import json
14from pathlib import Path
15import secrets
16import struct
17import subprocess
18import time
19
20ROOT = Path(__file__).resolve().parents[2]
21PACKAGE = 'net.paperclover.snowbound'
22SECTION = 'files/Notebooks/Snowbound/New Section 1.one'
23
24
25def 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
104if __name__ == '__main__':
105 main()