Add headless Android runtime diagnostics
Sync changed files to Google Drive / sync (push) Successful in 1m9s

This commit is contained in:
admin_d committed 2026-09-15 23:38:30 +05:00
1 parent 809b381918
commit e253946fc8
5 files changed
+1540 -19

No files matched your search

@@ -0,0 +1,7 @@
serial: emulator-5554
android: 11/30
ro_product_cpu_abi: x86_64
ro_product_cpu_abilist: x86_64,x86,arm64-v8a,armeabi-v7a,armeabi
launcher_activity: org.kammobile.kamremake/io.castleengine.MainActivity
pid_after_startup_wait: 4139
runtime_result: RUNTIME PASS
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
{ "schema" : "kam-mobile-diagnostics/1", "application" : { "name" : "kam_mobile", "package" : "org.kammobile.kamremake", "version" : "0.4.0", "version_code" : 30, "build_id" : null }, "runtime" : { "platform" : "android", "os_version" : null, "device_model" : null, "cpu_arch" : null }, "selected_map" : { "relative_path" : "Maps/CatchTheSnake/CatchTheSnake.map", "url" : "castle-data:/game/Maps/CatchTheSnake/CatchTheSnake.map" }, "overall_status" : "PASS", "actions" : [{ "id" : "file_io", "status" : "PASS", "failure_stage" : null, "error" : null, "stages" : [{ "id" : "resource_exists", "status" : "PASS" }, { "id" : "resource_open", "status" : "PASS" }, { "id" : "caller_stream_load", "status" : "PASS" }, { "id" : "stream_state", "status" : "PASS" }, { "id" : "seek_read_consistency", "status" : "PASS" }], "stream" : { "size" : 10286, "position_after_load" : 0, "read_offset" : 0, "requested_read_count" : 32, "first_read_count" : 32, "position_after_first_read" : 32, "seek_back_position" : 0, "second_read_count" : 32, "position_after_second_read" : 32, "repeat_equal" : true } }] }
+77 -17
View File
@@ -1,18 +1,21 @@
"""Install and launch the current Android application package.""" """Install, launch, and capture startup diagnostics for the current APK."""
from __future__ import annotations from __future__ import annotations
import argparse, json, shutil, subprocess, xml.etree.ElementTree as ET import argparse, json, os, shutil, subprocess, time, xml.etree.ElementTree as ET
from pathlib import Path from pathlib import Path
from common import (ROOT, CPUS, ProbeError, sha256, CURRENT_BUILD_METADATA, from common import (ROOT, CPUS, ProbeError, sha256, CURRENT_BUILD_METADATA,
CURRENT_PACKAGE_METADATA, CURRENT_APK, CURRENT_BUILD_SCHEMA, CURRENT_PACKAGE_METADATA, CURRENT_APK, CURRENT_BUILD_SCHEMA,
CURRENT_PACKAGE_SCHEMA) CURRENT_PACKAGE_SCHEMA)
AVD_NAME='kam_mobile_api30'
BOOT_TIMEOUT=180
class Adb: class Adb:
def __init__(self, serial: str | None): self.base=['adb']+(['-s',serial] if serial else []) def __init__(self, serial: str | None): self.base=[shutil.which('adb') or 'adb']+(['-s',serial] if serial else [])
def run(self,*args:str,check:bool=True,timeout:int=120)->subprocess.CompletedProcess: def run(self,*args:str,check:bool=True,timeout:int=120)->subprocess.CompletedProcess:
p=subprocess.run(self.base+list(args),capture_output=True,timeout=timeout) p=subprocess.run(self.base+list(args),capture_output=True,timeout=timeout)
if check and p.returncode: raise ProbeError('ADB command failed: '+' '.join(args)+'\n'+p.stderr.decode(errors='replace')) if check and p.returncode: raise ProbeError('ADB command failed: '+' '.join(args)+'\n'+p.stderr.decode(errors='replace'))
return p return p
def text(self,*args:str,check:bool=True)->str: return self.run(*args,check=check).stdout.decode(errors='replace').strip() def text(self,*args:str,check:bool=True,timeout:int=120)->str: return self.run(*args,check=check,timeout=timeout).stdout.decode(errors='replace').strip()
def fail(message: str) -> None: raise ProbeError(message) def fail(message: str) -> None: raise ProbeError(message)
@@ -50,6 +53,46 @@ def current_package() -> tuple[dict, str]:
if not CURRENT_APK.is_file() or apk.get('sha256')!=sha256(CURRENT_APK): fail('Current APK missing or changed') if not CURRENT_APK.is_file() or apk.get('sha256')!=sha256(CURRENT_APK): fail('Current APK missing or changed')
return package, identity['package'] return package, identity['package']
def authorized_devices() -> list[str]:
return [line.split()[0] for line in Adb(None).text('devices').splitlines()[1:]
if len(line.split()) >= 2 and line.split()[1] == 'device']
def start_avd() -> str:
emulator=shutil.which('emulator')
if not emulator: fail('Android Emulator is not installed on host')
if AVD_NAME not in subprocess.run([emulator,'-list-avds'],capture_output=True,text=True,timeout=30).stdout.splitlines():
fail(f'Android AVD {AVD_NAME} is not available')
accel='on' if subprocess.run([emulator,'-accel-check'],capture_output=True,timeout=30).returncode==0 else 'off'
subprocess.Popen([emulator,'-avd',AVD_NAME,'-no-window','-no-audio','-no-boot-anim',
'-gpu','swiftshader_indirect','-accel',accel,'-no-snapshot-save'],
stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True,
env={**os.environ,'ANDROID_EMULATOR_WAIT_TIME_BEFORE_KILL':'180'})
Adb(None).run('wait-for-device',timeout=BOOT_TIMEOUT)
deadline=time.monotonic()+BOOT_TIMEOUT
while time.monotonic()<deadline:
devices=authorized_devices()
if len(devices)==1:
adb=Adb(devices[0])
if adb.text('shell','getprop','sys.boot_completed',check=False,timeout=10)=='1': return devices[0]
time.sleep(2)
fail(f'Android AVD {AVD_NAME} did not boot within {BOOT_TIMEOUT} seconds')
def choose_device(requested: str | None) -> str:
devices=authorized_devices()
if requested:
if requested in devices: return requested
if devices: fail('Requested serial is not authorized')
if not devices:
serial=start_avd()
if requested and serial!=requested: fail('Requested serial is not the prepared emulator')
return serial
if len(devices)!=1: fail('Require exactly one authorized device or use --serial')
return devices[0]
def write_runtime_device(path: Path, **values: str) -> None:
path.parent.mkdir(parents=True,exist_ok=True)
path.write_text(''.join(f'{key}: {value}\n' for key,value in values.items()),encoding='utf-8')
def main() -> int: def main() -> int:
ap=argparse.ArgumentParser(description=__doc__); ap.add_argument('--serial'); a=ap.parse_args() ap=argparse.ArgumentParser(description=__doc__); ap.add_argument('--serial'); a=ap.parse_args()
try: try:
@@ -63,24 +106,41 @@ def main() -> int:
if not shutil.which('adb'): if not shutil.which('adb'):
print('BLOCKED\nadb is not installed on host'); return 2 print('BLOCKED\nadb is not installed on host'); return 2
try: try:
devices=[line.split()[:2] for line in Adb(None).text('devices').splitlines()[1:] if line.strip()] serial=choose_device(a.serial); adb=Adb(serial)
if len(devices)!=1 or len(devices[0])!=2 or devices[0][1]!='device': fail('Require exactly one authorized device') abi=adb.text('shell','getprop','ro.product.cpu.abi')
serial=devices[0][0] abilist=adb.text('shell','getprop','ro.product.cpu.abilist')
if a.serial and a.serial!=serial: fail('Requested serial is not the sole authorized device') android=adb.text('shell','getprop','ro.build.version.release')+'/'+adb.text('shell','getprop','ro.build.version.sdk')
adb=Adb(serial) if package['abi'] not in abilist.split(','): fail('Device ABI does not support package ABI')
if package['abi'] not in adb.text('shell','getprop','ro.product.cpu.abilist').split(','): fail('Device ABI does not support package ABI')
except (ProbeError, OSError, subprocess.TimeoutExpired) as error: except (ProbeError, OSError, subprocess.TimeoutExpired) as error:
print('BLOCKED\n'+str(error)); return 2 print('BUILD PASS\nRUNTIME BLOCKED\n'+str(error)); return 2
logs=ROOT/'build-evidence'/package['build_id']/'logs'
logcat=logs/'runtime-logcat.txt'; device=logs/'runtime-device.txt'
launcher=''; pid=''; result='RUNTIME FAIL'
try: try:
install=adb.run('install','-r',str(CURRENT_APK),check=False) install=adb.run('install','-r',str(CURRENT_APK),check=False)
if install.returncode and b'INSTALL_FAILED_UPDATE_INCOMPATIBLE' in install.stdout+install.stderr: if install.returncode and b'INSTALL_FAILED_UPDATE_INCOMPATIBLE' in install.stdout+install.stderr:
adb.run('uninstall',pkg,check=False); install=adb.run('install',str(CURRENT_APK),check=False) adb.run('uninstall',pkg,check=False); install=adb.run('install',str(CURRENT_APK),check=False)
if install.returncode: fail('APK install failed') if install.returncode: fail('APK install failed: '+(install.stdout+install.stderr).decode(errors='replace').strip())
candidates=[line.strip() for line in adb.text('shell','cmd','package','resolve-activity','--brief','-a','android.intent.action.MAIN','-c','android.intent.category.LAUNCHER',pkg).splitlines() if line.strip()] candidates=[line.strip() for line in adb.text('shell','cmd','package','resolve-activity','--brief','-a','android.intent.action.MAIN','-c','android.intent.category.LAUNCHER',pkg).splitlines() if line.strip().startswith(pkg+'/')]
if len(candidates)!=1 or not candidates[0].startswith(pkg+'/'): fail('Cannot resolve exactly one application launcher activity') if len(candidates)!=1: fail('Cannot resolve exactly one application launcher activity')
if adb.run('shell','am','start','-W','-n',candidates[0],check=False).returncode: fail('Launch failed') launcher=candidates[0]
adb.run('shell','am','force-stop',pkg)
adb.run('logcat','-c')
if adb.run('shell','am','start','-W','-n',launcher,check=False).returncode: fail('Launch failed')
time.sleep(5)
pid=adb.text('shell','pidof',pkg,check=False)
result='RUNTIME PASS' if pid else 'RUNTIME FAIL'
except (ProbeError, OSError, subprocess.TimeoutExpired) as error: except (ProbeError, OSError, subprocess.TimeoutExpired) as error:
print('FAIL\n'+str(error)); return 1 result='RUNTIME FAIL: '+str(error)
print('PASS'); return 0 finally:
logs.mkdir(parents=True,exist_ok=True)
try: captured=adb.run('logcat','-d','-v','threadtime',check=False).stdout.decode(errors='replace')
except (OSError, subprocess.TimeoutExpired) as error: captured='Could not capture logcat: '+str(error)+'\n'
logcat.write_text(captured,encoding='utf-8')
write_runtime_device(device,serial=serial,android=android,ro_product_cpu_abi=abi,
ro_product_cpu_abilist=abilist,launcher_activity=launcher,
pid_after_startup_wait=pid,runtime_result=result)
print('BUILD PASS\n'+result+'\nlogcat: '+str(logcat.relative_to(ROOT)))
return 0 if result=='RUNTIME PASS' else 1
if __name__=='__main__': raise SystemExit(main()) if __name__=='__main__': raise SystemExit(main())
+5 -2
View File
@@ -1,6 +1,6 @@
"""One-command Android-only compile, full native link, APK and evidence pipeline.""" """One-command Android-only compile, full native link, APK and evidence pipeline."""
from __future__ import annotations from __future__ import annotations
import argparse, json, os, re, shutil, subprocess, uuid, zipfile import argparse, json, os, re, shutil, subprocess, sys, uuid, zipfile
from pathlib import Path from pathlib import Path
from common import (ROOT, COMMIT, CPUS, ProbeError, stamp, sha256, write_json, run_command, from common import (ROOT, COMMIT, CPUS, ProbeError, stamp, sha256, write_json, run_command,
first_errors, CURRENT_OUTPUT_DIR, CURRENT_BUILD_METADATA, first_errors, CURRENT_OUTPUT_DIR, CURRENT_BUILD_METADATA,
@@ -242,6 +242,9 @@ def main()->int:
inherited=os.environ.get('KAM_BUILD_ID') inherited=os.environ.get('KAM_BUILD_ID')
if inherited and not re.fullmatch(r'\d{8}T\d{6}Z-[0-9a-f]{8}',inherited):ap.error('Invalid KAM_BUILD_ID') if inherited and not re.fullmatch(r'\d{8}T\d{6}Z-[0-9a-f]{8}',inherited):ap.error('Invalid KAM_BUILD_ID')
build_id=inherited or stamp()+'-'+uuid.uuid4().hex[:8] build_id=inherited or stamp()+'-'+uuid.uuid4().hex[:8]
return build_local(a,build_id) if a.local else docker_build(a,build_id) result=build_local(a,build_id) if a.local else docker_build(a,build_id)
if result==0 and os.environ.get('KAM_CONTAINER_BUILD')!='1':
subprocess.run([sys.executable,str(ROOT/'tools/device.py')],cwd=ROOT,check=False)
return result
if __name__=='__main__':raise SystemExit(main()) if __name__=='__main__':raise SystemExit(main())