diff --git a/.github/workflows/android-termius-study.yml b/.github/workflows/android-termius-study.yml
new file mode 100644
index 0000000000..9bdf91d44a
--- /dev/null
+++ b/.github/workflows/android-termius-study.yml
@@ -0,0 +1,80 @@
+name: Isolated API36 Termius official-channel study
+on:
+ pull_request:
+ branches: [candidate/android-mobile-key-enrollment-20261008]
+ types: [opened, synchronize, reopened]
+ paths: [docs/research/termius-android/**, .github/workflows/android-termius-study.yml]
+permissions:
+ contents: read
+concurrency:
+ group: termius-study-${{ github.head_ref }}
+ cancel-in-progress: true
+jobs:
+ study:
+ if: github.repository == 'Mafei/tabby' && github.event.pull_request.head.repo.full_name == 'Mafei/tabby' && github.head_ref == 'candidate/android-termius-runtime-study-20261009'
+ runs-on: ubuntu-24.04
+ timeout-minutes: 30
+ env:
+ # Existing explicit approval covers only this exact Android SDK agreement.
+ TABBY_ANDROID_SDK_LICENSE_APPROVED_SHA256: 1f8729233617b193fd619213792ae16a41b95d2bbbf525dfe66998252ba68b16
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ ref: ${{ github.event.pull_request.head.sha }}
+ - name: Disposable tool and evidence directories
+ run: |
+ printf 'ANDROID_HOME=%s/termius-study-sdk\nANDROID_SDK_ROOT=%s/termius-study-sdk\nANDROID_USER_HOME=%s/termius-study-user\nANDROID_AVD_HOME=%s/termius-study-avd\n' "$RUNNER_TEMP" "$RUNNER_TEMP" "$RUNNER_TEMP" "$RUNNER_TEMP" >> "$GITHUB_ENV"
+ mkdir -p study-artifacts
+ git rev-parse HEAD > study-artifacts/source-sha.txt
+ - name: Validate research consent and privacy guards
+ run: python3 -m unittest discover -s docs/research/termius-android -p 'test_*.py' -v
+ - name: Verify official Play image, ABI, approved agreements and install channel
+ id: preflight
+ run: python3 docs/research/termius-android/study.py preflight --output study-artifacts
+ - uses: actions/setup-java@v4
+ if: steps.preflight.outputs.can_install == 'true'
+ with:
+ distribution: temurin
+ java-version: 21
+ - uses: actions/setup-node@v4
+ if: steps.preflight.outputs.can_install == 'true'
+ with:
+ node-version: 24.19.0
+ - name: Install verified official SDK and Play image without accepting new terms
+ id: sdk
+ if: steps.preflight.outputs.can_install == 'true'
+ run: python3 docs/research/termius-android/study.py install --output study-artifacts
+ - name: Start only one disposable API36 emulator using existing KVM path
+ id: boot
+ if: steps.sdk.outcome == 'success'
+ run: |
+ trap 'python3 docs/research/termius-android/study.py diagnose --output study-artifacts' EXIT
+ node mobile/scripts/prepare-android-emulator.mjs --check-capacity
+ test -c /dev/kvm
+ study_runner_uid="$(id -u)"
+ sudo setfacl -m "u:${study_runner_uid}:rw" /dev/kvm
+ mkdir -p "$ANDROID_USER_HOME" "$ANDROID_AVD_HOME"
+ printf 'no\n' | "$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager" create avd --force --name termius-study-api36 --package 'system-images;android-36;google_apis_playstore;x86_64' --device pixel_7
+ test -f "$ANDROID_AVD_HOME/termius-study-api36.avd/config.ini"
+ "$ANDROID_HOME/emulator/emulator" -no-window -no-audio -version
+ "$ANDROID_HOME/emulator/emulator" -avd termius-study-api36 -no-window -no-audio -no-snapshot -no-boot-anim -gpu software -accel on -memory 4096 -cores 4 -port 5554 -verbose > "$RUNNER_TEMP/termius-study-emulator.log" 2>&1 &
+ "$ANDROID_HOME/platform-tools/adb" start-server
+ node mobile/scripts/prepare-android-emulator.mjs --serial emulator-5554 --api 36
+ - name: Observe official install entry and stop at login, terms or permissions
+ if: always() && steps.sdk.outcome == 'success'
+ run: python3 docs/research/termius-android/study.py observe --output study-artifacts --boot-ready '${{ steps.boot.outcome == 'success' }}'
+ - name: Stop only the disposable emulator
+ if: always()
+ run: |
+ if [ -x "$ANDROID_HOME/platform-tools/adb" ]; then
+ "$ANDROID_HOME/platform-tools/adb" -s emulator-5554 emu kill || true
+ fi
+ - name: Upload bounded public research evidence
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: Termius-API36-Official-Study-${{ github.event.pull_request.head.sha }}
+ path: study-artifacts/
+ if-no-files-found: error
+ retention-days: 14
diff --git a/docs/research/termius-android/README.md b/docs/research/termius-android/README.md
new file mode 100644
index 0000000000..c791c16c5e
--- /dev/null
+++ b/docs/research/termius-android/README.md
@@ -0,0 +1,74 @@
+# Isolated Termius Android runtime study
+
+This independent research candidate starts from sealed Android product
+`5ebd250a499f6dff52f4713f84ebcbece09b81c7`. It changes no product source,
+product acceptance gates, existing workflow, PR6, PR8 or desktop candidate.
+The user authorized one API36 emulator study and its independent draft PR.
+This does not authorize implementation of the proposed Termius-inspired UI.
+
+The new workflow runs only for this fork's exact research head targeting the
+sealed key-enrollment branch. It uses `contents: read`, checks out the exact PR
+head, has a separate concurrency group, and runs one Ubuntu 24.04 KVM job.
+It does not trigger the product Android matrix or merge/release anything.
+
+## First run: verify official installation prerequisites
+
+The proposed image is exactly
+`system-images;android-36;google_apis_playstore;x86_64`, with Pixel 7 geometry.
+This is a research image choice, not a change to the non-Play product matrix.
+Official SDK repository metadata must confirm its stable package, API and ABI.
+The existing SDK agreement SHA256
+`1f8729233617b193fd619213792ae16a41b95d2bbbf525dfe66998252ba68b16`
+is reused only after the exact published agreement text matches. The installer
+has no `--licenses`, automatic agreement reply or alternative download route.
+An added/changed agreement on a requested stable package is saved as public
+license evidence and blocks SDK installation. The published catalogs also
+contain unrelated preview packages; these are not selected or accepted, and
+the report limits selected-package/permission evidence to channel-0 records.
+SDK metadata/archives use Google's official repository and the
+existing pinned command-line-tools checksum.
+
+The official [Termius installation documentation](https://docs.termius.com/getting-started/download-termius)
+points Android users to [Google Play](https://play.google.com/store/apps/details?id=com.server.auditor.ssh.client).
+Public pages are checked without cookies, credentials or login. No unofficial
+APK, application-package redistribution or account export is used.
+
+If prerequisites pass, the job starts a fresh disposable API36 emulator using
+the existing KVM/capacity/readiness path. It records API, ABI, screen size,
+density and font scale, then opens only the official Play listing intent.
+It never clicks Install, Sign in, Accept, purchase or a permission button.
+The first run stops at the actual store/login/terms screen for review. A green
+completed research job is not successful Termius installation or Android
+product acceptance: consult `preflight.json` and `observation.json`.
+
+## Evidence and later observation scope
+
+CI artifacts bind the exact source SHA/tree, metadata hashes, selected
+agreements and actual `adb screencap` capture times/dimensions/SHA256. Fresh AVD
+data is never imported. A UI-tree privacy check rejects account-like text,
+password fields and nonempty editable fields before storing a screenshot.
+Screenshots receive pixel review after retrieval. No app logs, credential
+inputs, account data, AVD snapshot or Termius APK are published.
+The disposable emulator's startup diagnostic is limited to the last 160 lines
+and 128 KiB, with authentication-related lines omitted. It collects no Android
+logcat or application logs. This diagnostic does not relax the existing startup
+deadline, real native focus checks or product acceptance requirements.
+
+When official installation is possible within authorized boundaries, further
+research should record Termius version/installer/ABI and observe Hosts, session
+switching, IME open/closed, expanded/scrolling keys, paste, local preedit/input
+placement, application Home and system Home behavior. These are **not yet
+observed** by this first-run prerequisite harness. Account creation, credentials,
+new terms, paid features and unapproved permissions remain stop points. Real
+SSH hosts and the user's phone are excluded. Chinese IME behavior requires an
+actual available IME and cannot be inferred from synthetic InputConnection
+tests, marketing screenshots or iOS documentation.
+
+The official [AVD documentation](https://developer.android.com/studio/run/managing-avds)
+distinguishes Google APIs from Play Store images. The already completed
+[official-image UI review](https://github.com/Mafei/tabby/pull/8) remains a design
+artifact; Play listing images, actual emulator captures and our own design
+screens must retain distinct labels. No absence of a visible editor in a static
+image establishes how the app implements composition or input focus.
+
+AI disclosure: fully vibe coded by an AI coding agent.
diff --git a/docs/research/termius-android/study.py b/docs/research/termius-android/study.py
new file mode 100644
index 0000000000..56285266cb
--- /dev/null
+++ b/docs/research/termius-android/study.py
@@ -0,0 +1,353 @@
+#!/usr/bin/env python3
+"""Isolated API36 official-channel preflight. No account or consent automation."""
+import argparse
+from datetime import datetime, timezone
+import hashlib
+from html.parser import HTMLParser
+import importlib.util
+import json
+import os
+from pathlib import Path
+import re
+import struct
+import subprocess
+import time
+import urllib.error
+import urllib.request
+import xml.etree.ElementTree as ET
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[3]
+SPEC = importlib.util.spec_from_file_location('approved_sdk', ROOT / 'mobile/scripts/install-android-ci.py')
+SDK = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(SDK)
+PACKAGE = 'system-images;android-36;google_apis_playstore;x86_64'
+IMAGE = {'package': PACKAGE, 'platform': '36', 'tag': 'google_apis_playstore'}
+CATALOG = 'https://dl.google.com/android/repository/sys-img/google_apis_playstore/sys-img2-3.xml'
+INSTALL_PACKAGES = ['platform-tools', 'emulator', PACKAGE]
+TERMIUS = 'com.server.auditor.ssh.client'
+OFFICIAL_DOC = 'https://docs.termius.com/getting-started/download-termius'
+PLAY = 'https://play.google.com/store/apps/details?id=' + TERMIUS
+
+
+def utc():
+ return datetime.now(timezone.utc).isoformat()
+
+
+def digest(data):
+ return hashlib.sha256(data).hexdigest()
+
+
+def write_json(path, value):
+ path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + '\n')
+
+
+def run(args, timeout=30, binary=False):
+ result = subprocess.run(args, capture_output=True, timeout=timeout, check=True)
+ return result.stdout if binary else result.stdout.decode('utf-8', errors='replace').strip()
+
+
+def download(url, maximum=8 * 1024 * 1024):
+ # Normal official URLs only. No cookies, login, proxy changes or denied-route retry.
+ request = urllib.request.Request(url, headers={'User-Agent': 'Tabby-Android-UI-Research'})
+ with urllib.request.urlopen(request, timeout=30) as response:
+ data = response.read(maximum + 1)
+ if len(data) > maximum:
+ raise ValueError('DOWNLOAD_SIZE_LIMIT')
+ return data
+
+
+def packages(root, names):
+ return [p for p in SDK.children(root, 'remotePackage') if p.get('path') in names
+ and len(SDK.children(p, 'channelRef')) == 1
+ and SDK.children(p, 'channelRef')[0].get('ref') == 'channel-0']
+
+
+def license_evidence(root, selected, output):
+ refs = sorted({x.get('ref') for p in packages(root, selected) for x in SDK.children(p, 'uses-license')})
+ evidence = []
+ for ref in refs:
+ if not ref or not re.fullmatch(r'[a-z0-9-]+', ref):
+ raise ValueError('LICENSE_ID_INVALID')
+ values = [x.text for x in SDK.children(root, 'license') if x.get('id') == ref]
+ item = {'id': ref, 'definitions': len(values), 'alreadyApproved': False}
+ if len(values) == 1 and isinstance(values[0], str):
+ data = values[0].encode()
+ item.update({'sha256': digest(data), 'bytes': len(data)})
+ item['alreadyApproved'] = ref == 'android-sdk-license' and digest(data) == SDK.LICENSE_SHA256
+ (output / ('license-' + ref + '.txt')).write_bytes(data)
+ evidence.append(item)
+ return evidence
+
+
+class Links(HTMLParser):
+ def __init__(self):
+ super().__init__()
+ self.links = []
+
+ def handle_starttag(self, tag, attrs):
+ if tag == 'a':
+ value = dict(attrs).get('href', '')
+ if value.startswith('https://play.google.com/store/apps/details?') and ('id=' + TERMIUS) in value:
+ self.links.append(value)
+
+
+def official_channel():
+ result = {'officialDocumentation': OFFICIAL_DOC, 'playListing': PLAY,
+ 'appPackage': TERMIUS, 'apkDownloaded': False, 'versionVerified': False}
+ try:
+ text = download(OFFICIAL_DOC).decode('utf-8')
+ parser = Links()
+ parser.feed(text)
+ # GitBook may serve Markdown rather than HTML.
+ result['documentationLinksPlay'] = bool(parser.links) or bool(re.search(
+ r'https://play\.google\.com/store/apps/details\?id=com\.server\.auditor\.ssh\.client', text))
+ result['documentationHTTP'] = 200
+ except (urllib.error.URLError, ValueError) as error:
+ result['documentationError'] = type(error).__name__
+ if isinstance(error, urllib.error.HTTPError):
+ result['documentationHTTP'] = error.code
+ try:
+ text = download(PLAY).decode('utf-8')
+ result['listingHTTP'] = 200
+ result['listingNamesPublisher'] = 'Termius Corporation' in text
+ except (urllib.error.URLError, ValueError) as error:
+ result['listingError'] = type(error).__name__
+ if isinstance(error, urllib.error.HTTPError):
+ result['listingHTTP'] = error.code
+ return result
+
+
+def preflight(output):
+ report = {'createdUTC': utc(), 'sourceSHA': run(['git', 'rev-parse', 'HEAD']),
+ 'sourceTree': run(['git', 'rev-parse', 'HEAD^{tree}']),
+ 'package': PACKAGE, 'requestedRuntimeAPI': 36, 'requestedABI': 'x86_64',
+ 'metadataURLs': [SDK.REPOSITORY_URL, CATALOG], 'canInstall': False,
+ 'sdkInstalled': False, 'termiusInstalled': False, 'termiusRan': False,
+ 'evidenceScope': 'requested channel-0 records only; catalog previews not selected or accepted',
+ 'officialChannel': official_channel(), 'licenseEvidence': []}
+ try:
+ if os.environ.get('TABBY_ANDROID_SDK_LICENSE_APPROVED_SHA256') != SDK.LICENSE_SHA256:
+ raise ValueError('APPROVED_SDK_CONSENT_NOT_PRESENT')
+ selected = set()
+ for name, url, wanted, image in [
+ ('repository.xml', SDK.REPOSITORY_URL, {'platform-tools', 'emulator', 'cmdline-tools;22.0'}, None),
+ ('play-images.xml', CATALOG, {PACKAGE}, IMAGE),
+ ]:
+ data = download(url)
+ (output / name).write_bytes(data)
+ root = ET.fromstring(data)
+ report.setdefault('catalogs', []).append({'file': name, 'sha256': digest(data)})
+ if image:
+ stable = [p for p in packages(root, wanted) if len(SDK.children(p, 'channelRef')) == 1
+ and SDK.children(p, 'channelRef')[0].get('ref') == 'channel-0']
+ report['imageMetadata'] = {'exactStableRecords': len(stable)}
+ if len(stable) == 1:
+ details = SDK.children(stable[0], 'type-details')
+ if len(details) == 1:
+ report['imageMetadata'].update({
+ 'apiValues': [x.text for x in SDK.children(details[0], 'api-level')],
+ 'abiValues': [x.text for x in SDK.children(details[0], 'abi')],
+ 'tagIDs': [x.text for t in SDK.children(details[0], 'tag') for x in SDK.children(t, 'id')]})
+ report['licenseEvidence'].extend(license_evidence(root, wanted, output))
+ report.setdefault('selectedPackages', []).extend(
+ {'path': p.get('path'), 'agreements': [x.get('ref') for x in SDK.children(p, 'uses-license')]}
+ for p in packages(root, wanted))
+ selected.update(SDK.verified_packages(root, wanted, image))
+ if selected != {'platform-tools', 'emulator', 'cmdline-tools;22.0', PACKAGE}:
+ raise ValueError('EXACT_STABLE_PACKAGE_NOT_FOUND')
+ report.update({'status': 'OFFICIAL_IMAGE_VERIFIED', 'canInstall': True})
+ except (SystemExit, ValueError, ET.ParseError, urllib.error.URLError) as error:
+ code = str(error) if isinstance(error, (SystemExit, ValueError)) else type(error).__name__
+ report.update({'status': 'PREFLIGHT_BLOCKED', 'blocker': code})
+ if isinstance(error, urllib.error.HTTPError):
+ report['httpStatus'] = error.code
+ write_json(output / 'preflight.json', report)
+ if os.environ.get('GITHUB_OUTPUT'):
+ with open(os.environ['GITHUB_OUTPUT'], 'a') as target:
+ target.write('can_install=' + str(report['canInstall']).lower() + '\n')
+ if os.environ.get('GITHUB_STEP_SUMMARY'):
+ with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as target:
+ target.write('## Termius Android research\n\n' + report['status'] + '\n\n')
+ target.write('Termius has not been installed or run. No account, consent or permission automation.\n')
+ if not report['canInstall']:
+ target.write('\nBlocker: `' + report['blocker'] + '`\n')
+ print(json.dumps(report, indent=2))
+ return report
+
+
+def install(output):
+ report = json.loads((output / 'preflight.json').read_text())
+ if not report['canInstall'] or report['sourceSHA'] != run(['git', 'rev-parse', 'HEAD']):
+ raise ValueError('VERIFIED_PREFLIGHT_REQUIRED')
+ if os.environ.get('TABBY_ANDROID_SDK_LICENSE_APPROVED_SHA256') != SDK.LICENSE_SHA256:
+ raise ValueError('APPROVED_SDK_CONSENT_NOT_PRESENT')
+ for name, wanted, image in [('repository.xml', {'platform-tools', 'emulator', 'cmdline-tools;22.0'}, None),
+ ('play-images.xml', {PACKAGE}, IMAGE)]:
+ data = (output / name).read_bytes()
+ expected = next(x['sha256'] for x in report['catalogs'] if x['file'] == name)
+ if digest(data) != expected or SDK.verified_packages(ET.fromstring(data), wanted, image) != wanted:
+ raise ValueError('PREFLIGHT_BYTES_CHANGED')
+ sdk = Path(os.environ['ANDROID_HOME']).resolve()
+ if sdk.name != 'termius-study-sdk' or not sdk.is_relative_to(Path(os.environ['RUNNER_TEMP']).resolve()):
+ raise ValueError('DISPOSABLE_SDK_DIRECTORY_REQUIRED')
+ if sdk.exists() and any(sdk.iterdir()):
+ raise ValueError('EMPTY_DISPOSABLE_SDK_REQUIRED')
+ archive = download('https://dl.google.com/android/repository/commandlinetools-linux-15859902_latest.zip', 512 * 1024 * 1024)
+ if digest(archive) != SDK.CLI_SHA256:
+ raise ValueError('OFFICIAL_CLI_CHECKSUM_MISMATCH')
+ archive_path = sdk.parent / 'termius-study-cli.zip'
+ archive_path.write_bytes(archive)
+ sdk.mkdir(parents=True, exist_ok=True)
+ with zipfile.ZipFile(archive_path) as zipped:
+ for item in zipped.infolist():
+ if item.filename.startswith('/') or '..' in Path(item.filename).parts or (item.external_attr >> 16) & 0o170000 == 0o120000:
+ raise ValueError('UNSAFE_OFFICIAL_ARCHIVE')
+ zipped.extractall(sdk)
+ tools = sdk / 'cmdline-tools'
+ temporary = sdk / 'cli-unpacked'
+ tools.rename(temporary)
+ tools.mkdir()
+ temporary.rename(tools / 'latest')
+ for executable in (tools / 'latest/bin').iterdir():
+ executable.chmod(executable.stat().st_mode | 0o111)
+ licenses = sdk / 'licenses'
+ licenses.mkdir()
+ (licenses / 'android-sdk-license').write_text('\n' + SDK.ACCEPTED_SDK_HASH + '\n')
+ # Only the already approved agreement. An unexpected prompt has no stdin reply.
+ subprocess.run([str(tools / 'latest/bin/sdkmanager'), '--channel=0', '--sdk_root=' + str(sdk),
+ *INSTALL_PACKAGES], stdin=subprocess.DEVNULL, check=True, timeout=900)
+ report['sdkInstalled'] = True
+ write_json(output / 'preflight.json', report)
+
+
+def privacy_check(xml):
+ root = ET.fromstring(xml)
+ texts = [value for node in root.iter('node') for key in ['text', 'content-desc']
+ if (value := node.get(key, ''))]
+ if any(re.search(r'[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}', text) for text in texts):
+ raise ValueError('SCREEN_MAY_CONTAIN_PERSONAL_ACCOUNT')
+ for node in root.iter('node'):
+ if node.get('password') == 'true' or (node.get('class', '').endswith('EditText') and node.get('text', '')):
+ raise ValueError('SCREEN_CONTAINS_NONEMPTY_OR_PASSWORD_EDITOR')
+ return texts
+
+
+def bounded_startup_log(data, temporary):
+ # Only this fresh emulator's bounded startup tail; never logcat/app data.
+ text = data[-128 * 1024:].decode('utf-8', errors='replace')
+ lines = text.splitlines()[-160:]
+ return '\n'.join('[emulator authentication detail omitted]' if re.search(
+ r'jwt|jwks|token|authorization|bearer|secret', line, re.I) else
+ line.replace(str(temporary), '[RUNNER_TEMP]') for line in lines) + '\n'
+
+
+def diagnose(output):
+ temporary = Path(os.environ['RUNNER_TEMP']).resolve()
+ log = temporary / 'termius-study-emulator.log'
+ if log.is_file():
+ with log.open('rb') as source:
+ source.seek(max(0, log.stat().st_size - 128 * 1024))
+ text = bounded_startup_log(source.read(128 * 1024), temporary)
+ (output / 'emulator-startup.txt').write_text(text)
+ print(text)
+ adb = Path(os.environ['ANDROID_HOME']) / 'platform-tools/adb'
+ state = 'UNAVAILABLE'
+ try:
+ value = run([str(adb), '-s', 'emulator-5554', 'get-state'], timeout=5)
+ state = value if value in ['device', 'offline', 'bootloader'] else 'UNKNOWN'
+ except subprocess.SubprocessError:
+ pass
+ report = {'createdUTC': utc(), 'sourceSHA': run(['git', 'rev-parse', 'HEAD']),
+ 'serial': 'emulator-5554', 'adbState': state,
+ 'startupLogPresent': log.is_file(), 'appLogsCollected': False}
+ write_json(output / 'startup-diagnostic.json', report)
+ print(json.dumps(report, indent=2))
+
+
+def observe(output, ready):
+ adb = str(Path(os.environ['ANDROID_HOME']) / 'platform-tools/adb')
+ def command(*args, binary=False):
+ return run([adb, '-s', 'emulator-5554', *args], binary=binary)
+ report = {'createdUTC': utc(), 'sourceSHA': run(['git', 'rev-parse', 'HEAD']),
+ 'actualEmulator': False, 'actualTermiusUI': False, 'termiusInstalled': False,
+ 'bootReadinessPassed': ready, 'screenshots': [],
+ 'noUserInputEntered': True, 'noAccountImported': True, 'noPermissionsGranted': True,
+ 'notAndroidProductAcceptance': True}
+ try:
+ if command('get-state') != 'device':
+ raise ValueError('EMULATOR_UNAVAILABLE')
+ qemu = command('shell', 'getprop', 'ro.boot.qemu') or command('shell', 'getprop', 'ro.kernel.qemu')
+ api = command('shell', 'getprop', 'ro.build.version.sdk')
+ abi = command('shell', 'getprop', 'ro.product.cpu.abi')
+ if qemu != '1' or api != '36' or abi != 'x86_64':
+ raise ValueError('SINGLE_API36_CLOUD_EMULATOR_REQUIRED')
+ report.update({'actualEmulator': True, 'api': int(api), 'abi': abi,
+ 'resolution': command('shell', 'wm', 'size'), 'density': command('shell', 'wm', 'density'),
+ 'fontScale': command('shell', 'settings', 'get', 'system', 'font_scale'),
+ 'model': command('shell', 'getprop', 'ro.product.model')})
+ def capture(name):
+ command('shell', 'uiautomator', 'dump', '/data/local/tmp/termius-study-ui.xml')
+ xml = command('shell', 'cat', '/data/local/tmp/termius-study-ui.xml')
+ texts = privacy_check(xml)
+ png = command('exec-out', 'screencap', '-p', binary=True)
+ if png[:8] != b'\x89PNG\r\n\x1a\n':
+ raise ValueError('SCREENSHOT_NOT_PNG')
+ (output / name).write_bytes(png)
+ report['screenshots'].append({'file': name, 'source': 'actual adb screencap; fresh disposable API36 emulator',
+ 'capturedUTC': utc(), 'bytes': len(png), 'sha256': digest(png),
+ 'dimensions': list(struct.unpack('>II', png[16:24])),
+ 'privacy': 'UI tree checked; no input, imported account or user data'})
+ return texts
+ capture('01-emulator-startup.png')
+ installed = command('shell', 'pm', 'list', 'packages', TERMIUS)
+ report['termiusInstalled'] = ('package:' + TERMIUS) in installed.splitlines()
+ if not ready:
+ report['status'] = 'EMULATOR_STARTUP_BLOCKED'
+ elif 'package:com.android.vending' not in command('shell', 'pm', 'list', 'packages', 'com.android.vending').splitlines():
+ report['status'] = 'OFFICIAL_PLAY_STORE_UNAVAILABLE'
+ else:
+ # Opens the official store only. Never clicks Install, Sign in, Accept or a permission.
+ command('shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d',
+ 'market://details?id=' + TERMIUS, '-p', 'com.android.vending')
+ time.sleep(3)
+ texts = capture('02-official-play-entry.png')
+ screen = '\n'.join(texts).lower()
+ report['status'] = 'OFFICIAL_INSTALL_REQUIRES_USER_ACTION'
+ if re.search(r'sign in|sign into|log in', screen):
+ report['blocker'] = 'GOOGLE_PLAY_SIGN_IN_REQUIRED'
+ elif 'terms' in screen or 'agree' in screen:
+ report['blocker'] = 'OFFICIAL_STORE_TERMS_REQUIRE_REVIEW'
+ else:
+ report['blocker'] = 'STORE_SCREEN_REQUIRES_REVIEW_BEFORE_INSTALL'
+ report['observedStudyCoverage'] = 'official store entry only; Termius Hosts/terminal/IME/Home unobserved'
+ except (ValueError, ET.ParseError, subprocess.SubprocessError) as error:
+ report.update({'status': 'OBSERVATION_BLOCKED', 'blocker': str(error) if isinstance(error, ValueError) else type(error).__name__})
+ write_json(output / 'observation.json', report)
+ if os.environ.get('GITHUB_STEP_SUMMARY'):
+ with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as target:
+ target.write('\nObservation: **' + report['status'] + '**. Actual Termius UI observed: **false**.\n')
+ if 'blocker' in report:
+ target.write('\nBlocker: `' + report['blocker'] + '`\n')
+ print(json.dumps(report, indent=2))
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument('phase', choices=['preflight', 'install', 'diagnose', 'observe'])
+ parser.add_argument('--output', type=Path, required=True)
+ parser.add_argument('--boot-ready', choices=['true', 'false'], default='false')
+ args = parser.parse_args()
+ args.output.mkdir(parents=True, exist_ok=True)
+ if args.phase == 'preflight':
+ preflight(args.output)
+ elif args.phase == 'install':
+ install(args.output)
+ elif args.phase == 'diagnose':
+ diagnose(args.output)
+ else:
+ observe(args.output, args.boot_ready == 'true')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/docs/research/termius-android/test_study.py b/docs/research/termius-android/test_study.py
new file mode 100644
index 0000000000..ba1ead6a57
--- /dev/null
+++ b/docs/research/termius-android/test_study.py
@@ -0,0 +1,140 @@
+"""Safety-path tests; synthetic metadata is not emulator or app acceptance."""
+import hashlib
+from contextlib import redirect_stdout
+import io
+import importlib.util
+import json
+from pathlib import Path
+import tempfile
+import unittest
+from unittest.mock import patch
+import urllib.error
+import xml.etree.ElementTree as ET
+
+SPEC = importlib.util.spec_from_file_location('termius_study', Path(__file__).with_name('study.py'))
+study = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(study)
+LICENSE = 'synthetic approved SDK agreement'
+DIGEST = hashlib.sha256(LICENSE.encode()).hexdigest()
+
+
+def catalog(image=False, agreement='android-sdk-license', abi='x86_64'):
+ root = ET.Element('repository')
+ ET.SubElement(root, 'license', id='android-sdk-license').text = LICENSE
+ if agreement != 'android-sdk-license':
+ ET.SubElement(root, 'license', id=agreement).text = 'unapproved agreement'
+ names = [study.PACKAGE] if image else ['platform-tools', 'emulator', 'cmdline-tools;22.0']
+ for name in names:
+ p = ET.SubElement(root, 'remotePackage', path=name)
+ ET.SubElement(ET.SubElement(p, 'revision'), 'major').text = '1'
+ ET.SubElement(p, 'channelRef', ref='channel-0')
+ ET.SubElement(p, 'uses-license', ref=agreement)
+ if image:
+ detail = ET.SubElement(p, 'type-details')
+ ET.SubElement(detail, 'api-level').text = '36'
+ ET.SubElement(detail, 'abi').text = abi
+ ET.SubElement(ET.SubElement(detail, 'tag'), 'id').text = 'google_apis_playstore'
+ return ET.tostring(root)
+
+
+class StudyGuards(unittest.TestCase):
+ def preflight(self, directory, image, repository=None):
+ def downloaded(url):
+ return (repository or catalog()) if url == study.SDK.REPOSITORY_URL else image
+ with patch.object(study.SDK, 'LICENSE_SHA256', DIGEST), \
+ patch.dict(study.os.environ, {'TABBY_ANDROID_SDK_LICENSE_APPROVED_SHA256': DIGEST}, clear=True), \
+ patch.object(study, 'run', return_value='synthetic-sha'), \
+ patch.object(study, 'official_channel', return_value={'apkDownloaded': False}), \
+ patch.object(study, 'download', side_effect=downloaded), redirect_stdout(io.StringIO()):
+ return study.preflight(directory)
+
+ def test_approved_exact_image_can_progress_but_installs_nothing(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ directory = Path(temporary)
+ r = self.preflight(directory, catalog(image=True))
+ self.assertTrue(r['canInstall'])
+ self.assertFalse(r['sdkInstalled'])
+ self.assertFalse(r['termiusInstalled'])
+ self.assertEqual(r['package'], study.PACKAGE)
+
+ def test_additional_agreement_is_saved_for_review_and_never_accepted(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ directory = Path(temporary)
+ r = self.preflight(directory, catalog(image=True, agreement='android-sdk-arm-dbt-license'))
+ self.assertFalse(r['canInstall'])
+ self.assertEqual(r['blocker'], 'SDK_SELECTED_PACKAGE_ADDITIONAL_AGREEMENT')
+ self.assertFalse(r['licenseEvidence'][-1]['alreadyApproved'])
+ self.assertEqual(r['imageMetadata']['abiValues'], ['x86_64'])
+ self.assertEqual((directory / 'license-android-sdk-arm-dbt-license.txt').read_text(), 'unapproved agreement')
+
+ def test_foreign_abi_is_not_a_successful_api36_image(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ r = self.preflight(Path(temporary), catalog(image=True, abi='arm64-v8a'))
+ self.assertFalse(r['canInstall'])
+ self.assertEqual(r['blocker'], 'SDK_IMAGE_METADATA_INVALID')
+
+ def test_unselected_preview_agreement_is_not_selected_or_accepted(self):
+ root = ET.fromstring(catalog())
+ ET.SubElement(root, 'license', id='android-sdk-preview-license').text = 'unapproved preview'
+ preview = ET.SubElement(root, 'remotePackage', path='emulator')
+ ET.SubElement(preview, 'channelRef', ref='channel-1')
+ ET.SubElement(preview, 'uses-license', ref='android-sdk-preview-license')
+ with tempfile.TemporaryDirectory() as temporary:
+ directory = Path(temporary)
+ r = self.preflight(directory, catalog(image=True), ET.tostring(root))
+ self.assertTrue(r['canInstall'])
+ self.assertTrue(all(x['alreadyApproved'] for x in r['licenseEvidence']))
+ self.assertTrue(all(x['agreements'] == ['android-sdk-license'] for x in r['selectedPackages']))
+ self.assertFalse((directory / 'license-android-sdk-preview-license.txt').exists())
+ self.assertFalse(r['sdkInstalled'])
+
+ def test_http_denial_stops_without_alternate_download_or_sdk_install(self):
+ with tempfile.TemporaryDirectory() as temporary, \
+ patch.dict(study.os.environ, {'TABBY_ANDROID_SDK_LICENSE_APPROVED_SHA256': study.SDK.LICENSE_SHA256}, clear=True), \
+ patch.object(study, 'run', return_value='synthetic-sha'), \
+ patch.object(study, 'official_channel', return_value={}), \
+ patch.object(study, 'download', side_effect=urllib.error.HTTPError(study.SDK.REPOSITORY_URL, 403, 'denied', {}, None)) as download, \
+ redirect_stdout(io.StringIO()):
+ r = study.preflight(Path(temporary))
+ self.assertFalse(r['canInstall'])
+ self.assertEqual(r['httpStatus'], 403)
+ download.assert_called_once_with(study.SDK.REPOSITORY_URL)
+
+ def test_install_rejects_a_blocked_report_before_network_or_license_files(self):
+ with tempfile.TemporaryDirectory() as temporary, patch.object(study, 'download') as download:
+ directory = Path(temporary)
+ (directory / 'preflight.json').write_text(json.dumps({'canInstall': False}))
+ with self.assertRaisesRegex(ValueError, 'VERIFIED_PREFLIGHT_REQUIRED'):
+ study.install(directory)
+ download.assert_not_called()
+ self.assertEqual([x.name for x in directory.iterdir()], ['preflight.json'])
+
+ def test_capture_rejects_account_text_password_and_nonempty_editor(self):
+ for attributes in ["text='person@example.com'", "password='true'", "class='android.widget.EditText' text='typed-value'"]:
+ with self.subTest(attributes=attributes), self.assertRaises(ValueError):
+ study.privacy_check('')
+ self.assertEqual(study.privacy_check(''), ['Sign in'])
+
+ def test_preflight_bytes_cannot_change_between_review_and_install(self):
+ with tempfile.TemporaryDirectory() as temporary:
+ directory = Path(temporary)
+ self.preflight(directory, catalog(image=True))
+ (directory / 'repository.xml').write_bytes(b'')
+ with patch.object(study, 'run', return_value='synthetic-sha'), \
+ patch.dict(study.os.environ, {'TABBY_ANDROID_SDK_LICENSE_APPROVED_SHA256': study.SDK.LICENSE_SHA256}, clear=True), \
+ patch.object(study, 'download') as download:
+ with self.assertRaisesRegex(ValueError, 'PREFLIGHT_BYTES_CHANGED'):
+ study.install(directory)
+ download.assert_not_called()
+
+ def test_startup_diagnostic_is_bounded_and_omits_authentication_details(self):
+ data = ('line\n' * 200 + '/temporary/emulator boot\nBearer sensitive\njwt sensitive\n').encode()
+ result = study.bounded_startup_log(data, '/temporary')
+ self.assertEqual(len(result.splitlines()), 160)
+ self.assertNotIn('sensitive', result)
+ self.assertNotIn('/temporary', result)
+ self.assertIn('[RUNNER_TEMP]/emulator boot', result)
+
+
+if __name__ == '__main__':
+ unittest.main()