Release Vulpes OS 2.7-preview.22

This commit is contained in:
OverSu 2026-10-22 20:42:26 +02:00
commit a138f97877
294 changed files with 515786 additions and 187 deletions

View file

@ -45,7 +45,7 @@ if [ -z "${SYSTEM_IMAGE:-}" ] || [ "${APPLY_NATIVE_CONFIG:-0}" = 1 ]; then
done
fi
rm -f /newroot/etc/tundra/hardware-enabled /newroot/etc/systemd/system/tundra.target.wants/ofono.service /newroot/etc/systemd/system/tundra.target.wants/tundra-audio.service /newroot/etc/tundra/network-enabled /newroot/etc/systemd/system/tundra.target.wants/NetworkManager.service /newroot/etc/systemd/system/tundra.target.wants/tundra-wifi.service
tar -xf /native-config.tar -C /newroot
tar -xJf /native-config.tar -C /newroot
if [ -d /run/tundra-identity ]; then
for identity in /run/tundra-identity/*; do
[ -f "$identity" ] && cp -p "$identity" /newroot/etc/
@ -53,7 +53,7 @@ if [ -z "${SYSTEM_IMAGE:-}" ] || [ "${APPLY_NATIVE_CONFIG:-0}" = 1 ]; then
chmod 644 /newroot/etc/machine-id
fi
fi
if [ -f /shared-ui.tar ]; then tar -xf /shared-ui.tar -C /newroot; fi
if [ -f /shared-ui.tar ]; then tar -xJf /shared-ui.tar -C /newroot; fi
# A previous successful session must never count as proof of this boot.
for log in test.json home.png session.pid gecko.log server.log compositor.log network-gecko.json wifi-ui.json; do
rm -f "/newroot/opt/vulpes/logs/$log"

View file

@ -0,0 +1,6 @@
# Private boot trial: keep the vendor services, replace only the foreground UI.
service vulpes-trial /system/bin/sh /vulpes-boot.sh
class late_start
user root
group root
oneshot

View file

@ -0,0 +1,31 @@
#!/system/bin/sh
# SPDX-License-Identifier: MPL-2.0
# A missing runtime or failed launch must return to the installed KaiOS UI.
BASE=/data/local/vulpes-beatles
fallback() {
/system/bin/start b2g
exit 0
}
count=0
while [ "$count" -lt 120 ]; do
[ -f "$BASE/disable-boot" ] && fallback
[ -f "$BASE/runtime-ready" ] && [ -x "$BASE/rootfs/opt/firefox/firefox" ] &&
[ -e /dev/graphics/fb0 ] && [ -S /dev/socket/rilproxy ] && break
count=$((count + 1))
sleep 1
done
[ "$count" -lt 120 ] || fallback
rm -f "$BASE/display-session.done" "$BASE/rootfs/work/gaia-audit.json"
/sbin/vulpes-display --phone-1800s || fallback
# The supervisor has a 30-minute hard deadline. Check startup independently.
count=0
while [ "$count" -lt 150 ]; do
[ -f "$BASE/display-session.done" ] && fallback
if [ -s "$BASE/rootfs/work/gaia-audit.json" ]; then
/system/bin/busybox grep -q '"readyMs"' "$BASE/rootfs/work/gaia-audit.json" && exit 0
fi
count=$((count + 1))
sleep 1
done
touch "$BASE/stop-session"
fallback

View file

@ -0,0 +1,71 @@
# SPDX-License-Identifier: MPL-2.0
"""Android 6 audio services used by the Nokia firmware, outside Gecko.
Transaction IDs and argument types: AOSP android-6.0.1_r1,
frameworks/av/media/libmedia/{IAudioPolicyService,IAudioFlinger}.cpp.
This adapter is specific to that ABI. No arbitrary Binder transaction is exposed.
"""
import re
import subprocess
import time
class CallAudio:
def __init__(self):
self.mode=0;self.muted=False;self.speaker=False;self.available=False
self.original=None;self.retry_at=0
def transact(self,service,method,*values):
command=['/system/bin/service','call','media.audio_'+service,str(method)]
for value in values:command.extend(('i32',str(value)))
result=subprocess.run(command,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,
text=True,timeout=2,env={'PATH':'/system/bin','LD_LIBRARY_PATH':'/system/lib'})
match=re.fullmatch(r"Result: Parcel\(([0-9a-fA-F]{8})\s+'[^']*'\)\s*",result.stdout)
if result.returncode or not match:raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
return int(match[1],16)
def initialize(self):
if self.available:return
if time.monotonic()<self.retry_at:raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
try:
muted=self.transact('flinger',18);route=self.transact('policy',6,0)
if muted not in (0,1) or route not in range(16):raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
if self.original is None:self.original=(muted,route)
self.muted=bool(muted);self.speaker=route==1;self.available=True
except (OSError,RuntimeError,subprocess.SubprocessError):
self.retry_at=time.monotonic()+5;raise RuntimeError('CALL_AUDIO_UNAVAILABLE') from None
def set_mode(self,mode):
self.initialize()
if self.mode==mode:return
if self.transact('policy',3,mode):raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
self.mode=mode
def update(self,calls):
mode=2 if any(c['state'] in ('active','held','dialing','alerting') for c in calls) else 1 if calls else 0
try:self.set_mode(mode)
except (OSError,RuntimeError,subprocess.SubprocessError):
self.available=False;self.retry_at=time.monotonic()+5
def request(self,value):
operation=value.get('operation')
if operation not in ('status','set'):raise ValueError('INVALID_REQUEST')
for key in ('muted','speaker'):
if key in value and type(value[key]) is not bool:raise ValueError('INVALID_REQUEST')
self.initialize()
if operation=='set':
if 'speaker' in value and value['speaker']!=self.speaker:
if self.transact('policy',5,0,int(value['speaker'])):raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
actual=self.transact('policy',6,0)
if actual!=int(value['speaker']):raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
self.speaker=value['speaker']
if 'muted' in value and value['muted']!=self.muted:
if self.transact('flinger',17,int(value['muted'])):raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
if self.transact('flinger',18)!=int(value['muted']):raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
self.muted=value['muted']
return {'muted':self.muted,'speaker':self.speaker,'active':self.mode==2,'available':self.available}
def restore(self):
if self.original is None:return
muted,route=self.original
changes=[]
if self.mode:changes.append(('policy',3,(0,)))
if self.speaker!=(route==1):changes.append(('policy',5,(0,route)))
if self.muted!=bool(muted):changes.append(('flinger',17,(muted,)))
for service,method,values in changes:
try:self.transact(service,method,*values)
except (OSError,RuntimeError,subprocess.SubprocessError):pass

View file

@ -0,0 +1,223 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Small, independent Fira clock for the Nokia's RGB565 SPI cover panel."""
import ctypes as C
import bisect
import datetime
import fcntl
import json
import mmap
import re
from pathlib import Path
import signal
import struct
import time
def read_json(path):
try:
value=json.loads(Path(path).read_text())
return value if isinstance(value,dict) else {}
except (OSError,ValueError):return {}
SETTINGS=Path('/work/gaia-profile/vulpes-settings-v1.json')
class Animation:
"""Opt-in private test; decode happens on the PC, not in the phone loop."""
FRAME_BYTES=240*240*2
def __init__(self,path):
self.file=Path(path).open('rb');self.data=None
try:
size=self.file.seek(0,2)
if not 12<=size<=12+240*(4+self.FRAME_BYTES):raise ValueError('Animation size')
self.data=mmap.mmap(self.file.fileno(),0,access=mmap.ACCESS_READ)
magic,count=struct.unpack_from('<8sI',self.data)
if magic!=b'VULPANM1' or not 1<=count<=240 or size!=12+count*(4+self.FRAME_BYTES):
raise ValueError('Animation header')
self.ends=[];self.offsets=[];total=0
for index in range(count):
offset=12+index*(4+self.FRAME_BYTES)
duration,=struct.unpack_from('<I',self.data,offset)
if not 50<=duration<=1000:raise ValueError('Animation timing')
total+=duration;self.ends.append(total);self.offsets.append(offset+4)
except Exception:
self.close();raise
def frame(self,elapsed,stride):
phase=int(elapsed*1000)%self.ends[-1]
index=bisect.bisect_right(self.ends,phase);offset=self.offsets[index]
raw=self.data[offset:offset+self.FRAME_BYTES]
if stride!=480:
raw=b''.join(raw[y*480:(y+1)*480]+bytes(stride-480) for y in range(240))
return raw,(self.ends[index]-phase)/1000,index
def close(self):
if self.data is not None:self.data.close()
self.file.close()
def preferences(values):
title=values.get('vulpes.cover.title','VULPES')
title=''.join(c for c in title if c.isprintable()).strip()[:24] if isinstance(title,str) else ''
def color(key):
value=values.get(key,'#33a6ff')
if not isinstance(value,str) or not re.fullmatch(r'#[0-9a-fA-F]{6}',value):value='#33a6ff'
return tuple(int(value[i:i+2],16)/255 for i in (1,3,5))
return title or 'VULPES',color('vulpes.cover.titleColor'),color('vulpes.cover.batteryColor')
def labels(state,now):
if abs(now-state.get('recordedAt',0))>20:return ('—','—')
battery=state.get('battery') or {}
level=battery.get('level')
percent=str(round(level*100))+' %' if isinstance(level,(int,float)) and 0<=level<=1 else '—'
radios=state.get('radio') or []
network=next((r.get('operator',{}).get('longName','') for r in radios if r.get('available')), '')
return percent,network[:24] or '—'
def present(panel, variable):
# The display driver may reject a pan during a panel power transition.
# Record the failure and retry later; keep Gaia and the keypad running.
try:
fcntl.ioctl(panel,0x4606,variable,True)
return {'ok':True}
except OSError as error:
return {'ok':False,'errno':error.errno}
class Drawing:
def __init__(self,width,height,stride,font_path='/opt/vulpes/gaia/shared/elements/vulpes-fonts/FiraSans-Regular.otf'):
self.c=C.CDLL('libcairo.so.2');self.ft=C.CDLL('libfreetype.so.6')
p=C.c_void_p; i=C.c_int; d=C.c_double
signatures={
'cairo_image_surface_create_for_data':(p,[p,i,i,i,i]),
'cairo_create':(p,[p]), 'cairo_destroy':(None,[p]),
'cairo_surface_destroy':(None,[p]), 'cairo_surface_flush':(None,[p]),
'cairo_surface_write_to_png':(i,[p,C.c_char_p]),
'cairo_set_source_rgb':(None,[p,d,d,d]), 'cairo_paint':(None,[p]),
'cairo_rectangle':(None,[p,d,d,d,d]), 'cairo_fill':(None,[p]),
'cairo_move_to':(None,[p,d,d]), 'cairo_set_font_size':(None,[p,d]),
'cairo_show_text':(None,[p,C.c_char_p]), 'cairo_text_extents':(None,[p,C.c_char_p,p]),
'cairo_ft_font_face_create_for_ft_face':(p,[p,i]),
'cairo_set_font_face':(None,[p,p]),'cairo_font_face_destroy':(None,[p]),
'cairo_status':(i,[p]),
}
for name,(result,args) in signatures.items():
fn=getattr(self.c,name);fn.restype=result;fn.argtypes=args
self.ft.FT_Init_FreeType.argtypes=[C.POINTER(p)]
self.ft.FT_New_Face.argtypes=[p,C.c_char_p,C.c_long,C.POINTER(p)]
self.ft.FT_Done_Face.argtypes=[p];self.ft.FT_Done_FreeType.argtypes=[p]
self.library=p();self.face=p()
if self.ft.FT_Init_FreeType(C.byref(self.library)):raise RuntimeError('FreeType initialization')
font=str(font_path).encode()
if self.ft.FT_New_Face(self.library,font,0,C.byref(self.face)):raise RuntimeError('Gaia Fira font unavailable')
self.buffer=C.create_string_buffer(stride*height)
self.surface=self.c.cairo_image_surface_create_for_data(self.buffer,4,width,height,stride)
self.context=self.c.cairo_create(self.surface)
self.font=self.c.cairo_ft_font_face_create_for_ft_face(self.face,0)
self.c.cairo_set_font_face(self.context,self.font)
if self.c.cairo_status(self.context):raise RuntimeError('Cairo RGB565 context unavailable')
def text(self,text,size,y,color=(.92,.96,1)):
c=self.c;ctx=self.context;encoded=text.encode()
c.cairo_set_font_size(ctx,size)
extents=(C.c_double*6)();c.cairo_text_extents(ctx,encoded,extents)
# Fit custom titles without cropping wide letters or accented text.
if extents[2]>216:
c.cairo_set_font_size(ctx,size*216/extents[2])
c.cairo_text_extents(ctx,encoded,extents)
c.cairo_set_source_rgb(ctx,*color)
c.cairo_move_to(ctx,120-extents[2]/2-extents[0],y)
c.cairo_show_text(ctx,encoded)
def render(self,state):
c=self.c;ctx=self.context;now=datetime.datetime.now()
c.cairo_set_source_rgb(ctx,.015,.025,.045);c.cairo_paint(ctx)
title,title_color,battery_color=preferences(read_json(SETTINGS))
self.text(title,14,30,title_color)
self.text(now.strftime('%H:%M'),56,110)
self.text(now.strftime('%d / %m / %Y'),16,143,(.6,.7,.8))
battery,network=labels(state,time.time())
self.text(network,15,185)
self.text(battery,17,218,battery_color)
c.cairo_surface_flush(self.surface)
if c.cairo_status(ctx):raise RuntimeError('Cover rendering failed')
return self.buffer.raw
def close(self):
self.c.cairo_destroy(self.context);self.c.cairo_surface_destroy(self.surface)
self.c.cairo_font_face_destroy(self.font);self.ft.FT_Done_Face(self.face)
self.ft.FT_Done_FreeType(self.library)
def main():
if C.sizeof(C.c_void_p)!=4:raise RuntimeError('Nokia ARM32 framebuffer layout required')
running=True
def stop(*_):
nonlocal running
running=False
signal.signal(signal.SIGTERM,stop);signal.signal(signal.SIGINT,stop)
with open('/dev/fb1','r+b',buffering=0) as panel:
variable=bytearray(160);fixed=bytearray(68)
fcntl.ioctl(panel,0x4600,variable,True);fcntl.ioctl(panel,0x4602,fixed,True)
v=struct.unpack('=40I',variable);stride=struct.unpack_from('=I',fixed,44)[0]
length=struct.unpack_from('=I',fixed,20)[0]
if (v[0:2]!=(240,240) or v[6]!=16 or v[8:10]!=(11,5) or
v[11:13]!=(5,6) or v[14:16]!=(0,5) or v[4]!=0):
raise RuntimeError('Unsupported external panel format: '+repr(v[:17]))
offset=v[5]*stride;size=240*stride
if stride<480 or offset+size>length:raise RuntimeError('Framebuffer bounds invalid')
with mmap.mmap(panel.fileno(),length) as pixels:
original=pixels[offset:offset+size];drawing=Drawing(240,240,stride)
animation=None
try:animation=Animation('/work/cover-animation.rgb565')
except (OSError,ValueError) as error:
if not isinstance(error,FileNotFoundError):print('Cover animation unavailable:',type(error).__name__,flush=True)
try:
pixels[offset:offset+size]=drawing.render(read_json('/run/tundra-hardware/status.json'))
drawing.c.cairo_surface_write_to_png(drawing.surface,b'/work/cover-preview.png')
Path('/work/cover-ready').write_text('ready\n')
print('Cover ready: 240x240 RGB565, Gaia Fira Sans',flush=True)
last=0;was_on=False;last_error=None;animation_start=None;submitted=0;failed=0;pan_seconds=0
while running:
on=read_json('/work/lid-state.json').get('externalOn') is True
now=time.monotonic()
if on and not was_on and animation:
animation_start=now;submitted=0;failed=0;pan_seconds=0
if on and animation_start is not None:
elapsed=now-animation_start
if elapsed<8:
frame,delay,_=animation.frame(elapsed,stride)
pixels[offset:offset+size]=frame
before=time.monotonic();result=present(panel,variable)
pan_seconds+=time.monotonic()-before
if result['ok']:submitted+=1
else:failed+=1
was_on=on;last=0
time.sleep(max(.005,delay-(time.monotonic()-now)))
continue
Path('/work/cover-animation-report.json').write_text(json.dumps({
'seconds':elapsed,'submissions':submitted,'failed':failed,
'submissionsPerSecond':submitted/elapsed,
'meanPanMs':1000*pan_seconds/max(1,submitted+failed)}))
animation_start=None;last=0
if not on:animation_start=None
if on and (not was_on or time.monotonic()-last>=5):
pixels[offset:offset+size]=drawing.render(read_json('/run/tundra-hardware/status.json'))
result=present(panel,variable)
Path('/work/cover-refresh.json').write_text(json.dumps({**result,'recordedAt':time.time()}))
if not result['ok'] and result['errno']!=last_error:
print('Cover refresh deferred, errno:',result['errno'],flush=True)
if result['ok'] and last_error is not None:print('Cover refresh recovered',flush=True)
last_error=result.get('errno')
last=time.monotonic()
was_on=on;time.sleep(.2)
finally:
Path('/work/cover-ready').unlink(missing_ok=True)
pixels[offset:offset+size]=original;drawing.close()
if animation:animation.close()
if __name__=='__main__':main()

View file

@ -0,0 +1,200 @@
{
"schema": 1,
"device": "beatles",
"name": "Nokia 2720 Flip",
"architecture": "armv7",
"abi": "armeabi-v7a",
"soc": "MSM8905",
"board": "msm8909",
"taVariant": null,
"observedFirmware": {
"kaios": "2.5.2.2",
"gecko": "48.0a2",
"build": "40.00.17.01",
"androidHardwareBase": "6.0.1",
"androidApi": 23,
"kernel": "3.10.49-g8801858442f"
},
"display": {
"internal": {
"width": 240,
"height": 320
},
"external": {
"width": 240,
"height": 240
},
"touchscreen": false,
"clamshell": {
"sysfs": "/sys/devices/soc.0/hall_sensor1.73/hall_status",
"rawOpen": 0,
"rawClosed": 1,
"evidence": "Physical inversion reported by owner on 2026-10-18; software polarity corrected"
}
},
"memory": {
"kernelVisibleKiB": 409248,
"zramSwapKiB": 262140
},
"access": {
"adb": true,
"adbUid": 2000,
"debuggerChromeProcessUid": 0,
"bootloaderUnlocked": null
},
"validation": {
"nativeArmv7UserspaceProbe": true,
"diagnosticAppOnExistingKaiOS": true,
"newGeckoRuntime": true,
"gaiaBoot": true,
"physicalKeys": true,
"customBootImage": true,
"bootPartitionBackupsVerified": true,
"restorationTested": false,
"physicalKeysEvidence": "Owner reports diagnostic works well, 2026-10-17; no exhaustive keypad matrix qualification.",
"predictiveT9DictionariesBuilt": true,
"predictiveT9DictionaryLanguages": 42,
"predictiveT9PhysicalQualification": false,
"newGeckoOffscreenHtmlCssJs": true,
"newGeckoInteractiveDisplay": true,
"nativeXorgFramebuffer": true,
"framebufferCaptureVerified": true,
"nativeKeypadEventQualification": true,
"nativeRilStatus": true,
"nativeBatteryStatus": true,
"gaiaSimAndSignalStatus": true,
"nativeSms": true,
"nativeCalls": true,
"kernelEvdevKeypadQualification": true,
"nativePhoneBroker": true,
"nativeCallList": true,
"nativeAudioServiceAccess": true,
"nativeSmsCodecTests": true,
"externalDisplayRendered": true,
"physicalClamshellCycle": true,
"externalDisplayRefresh": true,
"nativeCallAudio": true,
"userQualification": "2026-10-18: owner confirms call audio in both directions and SMS; previous confirmation of clamshell/backlight operation. Not an exhaustive reliability test.",
"customBootImageBooted": false,
"persistentRuntime": true,
"persistentRuntimeSurvivedReboot": true,
"nativeVolumeControls": true,
"nativeBacklightControl": true,
"boundedVibration": true,
"nativeRadioPower": true,
"gaiaAirplaneMode": true,
"airplaneModeQualification": "tickets/evidence-2026-10-20/nokia-airplane.json"
},
"flashReady": false,
"evidence": [
"logs/nokia2720-inventory-20261017/inventory.json",
"tundra/out/beatles/armv7-smoke/device-test.json",
"tundra/out/beatles/display-diagnostic/install-report.json",
"tundra/out/beatles/backups/20261017/verification.json",
"tundra/out/beatles/gecko-armhf/version-result.json",
"tundra/out/beatles/gecko-armhf/render-result.json",
"tundra/out/beatles/gecko-armhf/gecko.png",
"tundra/out/beatles/gecko-156.0.1/engine-staging.json",
"tundra/out/beatles/gecko-156.0.1/version-result.json",
"tundra/out/beatles/gecko-156.0.1/render-result.json",
"tundra/out/beatles/gecko-156.0.1/gaia-trial-20261017-205052/gaia-audit.json",
"tundra/out/beatles/gecko-156.0.1/framebuffer-home-156.png",
"tundra/out/beatles/gecko-156.0.1/gaia-trial-20261017-205433/gaia-audit.json",
"tundra/out/beatles/gecko-156.0.1/qualification.json",
"tundra/out/beatles/gecko-156.0.1/gaia-trial-20261017-230112/gaia-audit.json",
"tundra/out/beatles/gecko-156.0.1/gaia-trial-20261017-233954/gaia-audit.json",
"tundra/out/beatles/gecko-156.0.1/gaia-trial-20261021-105254/camera-frame.png",
"tundra/out/beatles/gecko-156.0.1/gaia-trial-20261021-105554/camera-photo.jpg",
"tundra/out/beatles/gecko-156.0.1/gaia-trial-20261021-105554/camera-frame-encoded.jpg",
"tundra/out/beatles/gecko-156.0.1/gaia-trial-20261021-110735/display-session.log"
],
"experimentalEngine": {
"version": "156.0.1",
"architecture": "armv7 / hard-float",
"source": "Mozilla release sources, locally cross-compiled",
"sourceRevision": "6f2c158dfc7e9693f880fad2510ceb51a158c069",
"sourcePatches": [],
"mode": "shared Gaia, Xorg/fbdev software display, bounded private session with Nokia radio and Wi-Fi adapters"
},
"limitations": [
"KaiOS kernel and existing firmware; no standalone boot image",
"KaiOS stock Camera is confirmed working by the user; Vulpes Gecko 156 still has no beatles camera adapter. The standalone QCamera diagnostic returned empty frames, so its output is not evidence of a sensor or kernel fault",
"Software framebuffer around 11 submissions/second; no GPU acceleration",
"Multi-tap input qualified; offline predictive T9 is implemented for 42 Gaia keyboard locales, pending physical qualification",
"Gecko runs as uid 2000; root watchdog and Xorg restore KaiOS after each trial",
"First SIM only; no data bearer, MMS or camera adapter; sustained radio reliability and incoming call lifecycle still need qualification",
"External display refresh retries driver refusals during panel transitions",
"Bluetooth firmware backend exists, but Vulpes has no Nokia adapter; pairing and profiles are not available",
"Predictive T9 lexicons are generated from Gaia keyboard wordlists; 12,000 frequent words per locale, loaded on demand. User-dictionary learning is not implemented"
],
"privateBootBuild": {
"path": "tundra/out/beatles/boot-trial-20261018-r5",
"signed": false,
"bootTested": false,
"standalone": false,
"fastbootDetected": false,
"bootloaderLockState": null
},
"hardwareInventory": {
"camera": {
"count": 1,
"facing": "back",
"orientation": 270,
"moduleApi": "1.0",
"gaiaAdapter": false,
"maxPictureSize": [
1600,
1200
],
"focusModes": [
"fixed"
],
"maxDigitalZoom": 6.06,
"firmwareOpenReleaseVerified": true,
"advertisedFlashModes": [
"off",
"auto",
"on",
"torch"
],
"flashPhysicallyVerified": false,
"temporaryProbe": {
"date": "2026-10-21",
"backend": "mm-qcamera-app as Android camera uid 1006",
"previewUi": "QCamera preview starts; frames are not yet delivered to Gaia",
"snapshotYuv": "NV21 1280x960 captured and visually decoded",
"snapshotJpeg": "invalid for use; nearly uniform gray/black",
"gaiaAdapter": false,
"evidence": "tickets/2026-10-21.md",
"cameraDaemon": "qcamerasvr running; /system/bin/mm-qcamera-daemon (Android camera UID) keeps /dev/video0 busy after B2G stops",
"v4l2Direct": "unavailable; Qualcomm interface client required"
}
},
"volumeKeys": {
"up": "event3/115",
"down": "event2/114"
},
"sensors": {
"modulePresent": true,
"enumeratedAsShell": 0,
"enumeratedAsRoot": 0,
"automaticBrightnessAvailable": false,
"accelerometerAvailable": false
},
"wifi": {
"interface": "wlan0",
"controlSocket": "wpa_wlan0",
"gaiaAdapter": true,
"qualification": "tickets/evidence-2026-10-20/nokia-wifi.json"
},
"evidence": "tundra/out/beatles/hardware-inventory-20261018",
"qualification": "tundra/out/beatles/hardware-qualification-20261018/controls-final.json",
"bluetooth": {
"firmwareBackend": "bluetoothd",
"firmwareDaemonPresent": true,
"firmwareServiceRunning": false,
"vulpesAdapter": false,
"evidence": "tickets/evidence-2026-10-20/nokia-media-stack.txt"
}
}
}

View file

@ -0,0 +1,21 @@
/* SPDX-License-Identifier: MPL-2.0 */
(function () {
'use strict';
var pages = document.querySelectorAll('.page');
var current = 0;
function show(index) {
current = (index + pages.length) % pages.length;
for (var i = 0; i < pages.length; i++) {
pages[i].classList.toggle('active', i === current);
}
document.getElementById('position').textContent = (current + 1) + ' / ' + pages.length;
}
document.addEventListener('keydown', function (event) {
document.getElementById('last-key').textContent = event.key || String(event.keyCode);
if (event.key === 'ArrowRight' || event.key === 'Enter') {
event.preventDefault(); show(current + 1);
} else if (event.key === 'ArrowLeft') {
event.preventDefault(); show(current - 1);
}
});
}());

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 KiB

View file

@ -0,0 +1,39 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vulpes — Diagnostic Nokia</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header><img src="icon.png" alt=""><div><h1>VULPES</h1><p>Laboratoire · Nokia 2720</p></div></header>
<main>
<section class="page active">
<span class="eyebrow">PREMIER CONTACT</span>
<h2>Bonjour, Nokia.</h2>
<p><strong>Gecko 153.5 ESR</strong> a calculé une page HTML/CSS/JS sur ce Nokia.</p>
<div class="notice">Cet écran utilise <strong>KaiOS / Gecko 48</strong>.<br>Le moteur récent est testé hors écran. Gaia reste à porter.</div>
</section>
<section class="page">
<span class="eyebrow">LE MATÉRIEL</span>
<h2>Petit format.</h2>
<dl><dt>Architecture</dt><dd>ARMv7 · 32 bits</dd><dt>Noyau</dt><dd>Linux 3.10.49</dd><dt>Mémoire visible</dt><dd>400 Mio environ</dd><dt>Écran intérieur</dt><dd>240 × 320</dd></dl>
</section>
<section class="page">
<span class="eyebrow">ESSAI DES TOUCHES</span>
<h2>À toi de jouer.</h2>
<p>Appuie sur les chiffres, les flèches ou la touche centrale.</p>
<output id="last-key">En attente…</output>
<p class="small">Aucune donnée personnelle lue. Aucune partition système modifiée.</p>
</section>
<section class="page proof">
<span class="eyebrow">RENDU RÉEL · GECKO 153.5 ESR</span>
<img src="gecko-proof.png" alt="Page calculée sur le Nokia : Gecko 153 et JavaScript moderne OK">
<p class="small">Image du rendu hors écran.<br>Cette prévisualisation reste dans KaiOS.</p>
</section>
</main>
<footer><span>← →</span><span id="position">1 / 4</span><span>OK : suite</span></footer>
<script src="app.js"></script>
</body>
</html>

View file

@ -0,0 +1,10 @@
{
"name": "Vulpes Diagnostic",
"description": "Essai d’affichage et des touches du Nokia 2720. Utilise le Gecko de KaiOS ; ce n’est pas Vulpes OS installé.",
"version": "0.2.0",
"launch_path": "/index.html",
"developer": {"name": "OverSu", "url": "https://vulpes-os.org"},
"icons": {"128": "/icon.png"},
"default_locale": "fr",
"orientation": "portrait"
}

View file

@ -0,0 +1,22 @@
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; }
body { background: #07182c; color: #edf6ff; font: 14px/1.35 sans-serif; }
header { display: flex; align-items: center; padding: 10px 12px; border-bottom: 1px solid #24405c; }
header img { width: 32px; height: 32px; margin-right: 9px; object-fit: contain; }
h1 { font-size: 19px; letter-spacing: 3px; margin: 0; }
header p { margin: 0; font-size: 10px; color: #a8c3db; }
main { padding: 12px; }
.page { display: none; }
.page.active { display: block; }
.eyebrow { color: #75c7ff; font-size: 10px; letter-spacing: 1px; }
h2 { margin: 6px 0 9px; font-size: 23px; line-height: 1.1; }
p { margin: 8px 0; }
.notice { padding: 9px; margin-top: 12px; background: #14304a; border-left: 3px solid #60baff; font-size: 12px; }
dl { margin: 8px 0; }
dt { color: #a8c3db; font-size: 10px; }
dd { margin: 0 0 2px; }
output { display: block; padding: 10px; border: 1px solid #60baff; border-radius: 5px; overflow-wrap: break-word; }
.small { font-size: 11px; color: #a8c3db; }
.proof { text-align: center; }
.proof img { display: block; width: 96px; height: 128px; margin: 6px auto; }
footer { position: absolute; bottom: 0; left: 0; right: 0; display: flex; justify-content: space-between; padding: 6px 10px; background: #102b45; font-size: 11px; }

View file

@ -0,0 +1,37 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
import os
from pathlib import Path
import subprocess
import time
os.umask(0o022)
with Path('/work/xorg-console.log').open('w') as log:
server = subprocess.Popen(['/usr/lib/xorg/Xorg', ':0', 'vt1',
'-config', '/etc/X11/xorg.conf', '-logfile', '/work/Xorg.log',
'-nolisten', 'tcp', '-ac', '-noreset', '-novtswitch', '-sharevts'],
stdout=log, stderr=subprocess.STDOUT)
try:
for attempt in range(40):
if server.poll() is not None:
raise RuntimeError('Xorg exited: ' + str(server.returncode))
if Path('/tmp/.X11-unix/X0').exists():
break
time.sleep(0.1)
with Path('/work/keypad.log').open('w') as keys, Path('/work/x11-test.log').open('w') as client_log:
keypad = subprocess.Popen(['/work/keypad'], stdout=keys, stderr=subprocess.STDOUT)
try:
client = subprocess.Popen(['/work/x11-test'], stdout=client_log, stderr=subprocess.STDOUT)
time.sleep(2)
subprocess.run(['/work/keypad','--self-test'],check=True)
print('X11 client exit:', client.wait(timeout=28), flush=True)
finally:
keypad.terminate()
keypad.wait(timeout=2)
finally:
server.terminate()
try:
server.wait(timeout=3)
except subprocess.TimeoutExpired:
server.kill()
server.wait()

View file

@ -0,0 +1,169 @@
import {tundraRadio} from 'resource://vulpes/host/Tundra.sys.mjs';
import {setTimeout} from 'resource://gre/modules/Timer.sys.mjs';
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
export function install() {
Services.obs.addObserver(function opened(win) {
win.addEventListener('load',()=>{
if(win.location.href==='chrome://vulpes/content/shell.xhtml') run(win).catch(Cu.reportError);
},{once:true});
},'chrome-document-global-created');
}
async function run(win) {
const report={engine:Services.appinfo.platformVersion,build:Services.appinfo.platformBuildID,started:Date.now()};
const save=()=>IOUtils.writeJSON('/work/gaia-audit.json',report);
try {
for(let i=0;i<600&&!win.hostReady;i++)await sleep(200);
if(!win.hostReady)throw Error('Host startup timeout');
ChromeUtils.registerWindowActor('VulpesProbe',{
child:{esModuleURI:'resource://vulpes/tests/ProbeChild.sys.mjs'},
allFrames:true,safeForUntrustedWebProcess:true,
});
const system=win.document.getElementById('system').browsingContext;
const find=(context,app)=>{
if(context.currentWindowGlobal?.documentURI.spec.startsWith('http://'+app+'.localhost:8765/'))return context;
for(const child of context.children){const result=find(child,app);if(result)return result;}
return null;
};
const evaluate=(app,script)=>find(system,app).currentWindowGlobal.getActor('VulpesProbe').sendQuery('Evaluate',script);
for(let i=0;i<300;i++) {
if(find(system,'homescreen') && await evaluate('homescreen',"return document.querySelectorAll('gaia-app-icon').length>0;"))break;
await sleep(200);
}
report.system=await evaluate('system','return {ready:!!window.applications?.ready,size:[innerWidth,innerHeight]};');
report.homescreen=await evaluate('homescreen',"return {icons:document.querySelectorAll('gaia-app-icon').length,size:[innerWidth,innerHeight],text:document.body.innerText.slice(0,300)};");
for(let i=0;i<60;i++) {
report.icons=await evaluate('homescreen',"return [...document.querySelectorAll('gaia-app-icon')].map(e=>({id:e.dataset.identifier,loaded:!!e._hasIcon,pending:!!e._image,images:[...(e._container?.querySelectorAll('img') || [])].map(i=>({width:i.naturalWidth,complete:i.complete}))}));");
report.logoHidden=await evaluate('system',"var e=document.getElementById('os-logo');return !e || !e.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}) || Number(getComputedStyle(e).opacity)<.05;");
if(report.logoHidden&&report.icons.length>=4&&report.icons.slice(0,4).every(icon=>icon.images.some(image=>image.complete&&image.width>0)))break;
await sleep(500);
}
report.homescreen.icons=report.icons.length;
report.compactUI=await evaluate('homescreen',"return {height:innerHeight,singlePanel:document.getElementById('panels').scrollWidth===document.getElementById('panels').clientWidth,indicatorsHidden:getComputedStyle(document.getElementById('page-indicator-header')).display==='none',pinnedPagesHidden:getComputedStyle(document.getElementById('pages-panel')).display==='none'};");
report.homeSearchHidden=await evaluate('system',"return getComputedStyle(document.querySelector('#homescreen > .chrome')).display==='none';");
report.layout=await evaluate('homescreen',"return {mode:document.documentElement.dataset.vulpesLayout,icons:[...document.querySelectorAll('gaia-app-icon')].slice(0,9).map(e=>({rect:e.getBoundingClientRect().toJSON(),image:e._container?.querySelector('img')?.getBoundingClientRect().toJSON()}))};");
report.hardware=await evaluate('system',"return await VulpesCompat.call('platform.snapshot',{});");
report.radioAPI=await evaluate('system',"return {slots:navigator.mozMobileConnections?.length || 0,battery:navigator.battery.level,signal:!!document.getElementById('statusbar-mobile-connection'),radioUnavailable:document.documentElement.hasAttribute('data-vulpes-radio-unavailable'),cards:navigator.mozIccManager.iccIds.map(id=>navigator.mozIccManager.getIccById(id).cardState),connections:[...navigator.mozMobileConnections].map(c=>({radioState:c.radioState,registered:c.voice.connected,type:c.voice.type,signal:c.voice.relSignalStrength}))};");
if(Services.env.get('VULPES_TUNDRA_RADIO')==='1') {
for(let attempt=0;attempt<30;attempt++) {
report.phoneBroker=await tundraRadio('status');
if(report.phoneBroker.ready) {
try {report.currentCallCount=(await tundraRadio('calls')).length;break;} catch (_) {}
}
await sleep(1000);
}
if(report.currentCallCount===undefined)throw Error('Phone broker did not become ready');
try {report.callAudio=await tundraRadio('callAudio',{settings:{operation:'status'}});}
catch(error) {report.callAudio={error:error.message};}
try { await tundraRadio('sms',{number:'INVALID',body:'validation only'});report.invalidSmsRejected=false; }
catch(error) { report.invalidSmsRejected=error.message==='INVALID_NUMBER'; }
}
report.readyMs=Date.now()-report.started;
const image=await win.browsingContext.currentWindowGlobal.drawSnapshot(new win.DOMRect(0,0,win.innerWidth,win.innerHeight),1,'white');
const canvas=win.document.createElementNS('http://www.w3.org/1999/xhtml','canvas');
canvas.width=image.width;canvas.height=image.height;canvas.getContext('2d').drawImage(image,0,0);image.close();
const blob=await new Promise(resolve=>canvas.toBlob(resolve,'image/png'));
await IOUtils.write('/work/gaia-native.png',new Uint8Array(await blob.arrayBuffer()));
await save();
if(Services.env.get("VULPES_TRIAL_MANUAL")==="1") {
let last='';
while(!win.closed) {
try {
const request=await IOUtils.readJSON('/work/manual-request.json');
if(typeof request.id==='string' && request.id!==last) {
last=request.id;
let response;
try {
if(request.app==='screenshot') {
const picture=await win.browsingContext.currentWindowGlobal.drawSnapshot(new win.DOMRect(0,0,win.innerWidth,win.innerHeight),1,'white');
canvas.getContext('2d').drawImage(picture,0,0);picture.close();
const capture=await new Promise(resolve=>canvas.toBlob(resolve,'image/png'));
await IOUtils.write('/work/manual-screen.png',new Uint8Array(await capture.arrayBuffer()));
response={id:last,value:true};
} else response={id:last,value:await Promise.race([
evaluate(request.app,request.script),
sleep(30000).then(()=>{throw Error('Manual probe timed out');})
])};
} catch(error) {response={id:last,error:String(error)};}
await IOUtils.writeJSON('/work/manual-response.json',response,{tmpPath:'/work/manual-response.tmp'});
}
} catch (_) {}
await sleep(200);
}
return;
}
report.animation=await evaluate('homescreen',`return new Promise(resolve=>{let frames=0;const begin=performance.now();function tick(now){frames++;if(now-begin>=3000)resolve({frames,seconds:(now-begin)/1000,fps:frames*1000/(now-begin)});else requestAnimationFrame(tick);}requestAnimationFrame(tick);});`);
const until=async read=>{let value;for(let i=0;i<40;i++){value=await read();if(value)return value;await sleep(250);}return value;};
await evaluate('homescreen',"var first=document.querySelector('gaia-app-icon');first.setAttribute('tabindex','-1');first.focus();return document.activeElement===first;");
let sequence=0;
const key=async key=>{
const sent=++sequence;
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:sent,key},{tmpPath:'/work/keypad-request.json.tmp'});
for(let i=0;i<40;i++) {
try {if((await IOUtils.readJSON('/work/keypad-response.json')).sequence===sent)return;} catch (_) {}
await sleep(100);
}
throw Error('Keypad acknowledgement timed out');
};
report.inputPath='kernel evdev → keypad reader → XTest → GTK → host → Gaia';
await key('ArrowRight');
report.keypadSelection=await until(()=>evaluate('homescreen',"var id=document.querySelector('[data-vulpes-keypad-focus]')?.dataset.identifier;return id?.includes('sms.localhost') ? id : null;"));
if(!report.keypadSelection)throw Error('Right did not select Messages');
report.selectedAppName=await until(()=>evaluate('homescreen',"return document.getElementById('vulpes-compact-app-name')?.textContent==='Messages';"));
await key('Enter');
for(let i=0;i<150&&!find(system,'sms');i++)await sleep(200);
report.smsOpened=!!find(system,'sms');
if(report.smsOpened) {
await until(()=>evaluate('sms',"return document.readyState==='complete' && !!document.body;"));
await until(()=>evaluate('system',"return [...document.querySelectorAll('iframe')].some(f=>f.src.includes('sms.localhost')&&f.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}));"));
await evaluate('sms',"var input=document.createElement('input');input.id='vulpes-keypad-probe';input.type='tel';input.style='position:fixed;top:80px;left:0;z-index:99999';document.body.append(input);input.focus();return true;");
await key('2');
report.keypadInput=await until(()=>evaluate('sms',"return document.getElementById('vulpes-keypad-probe').value;"));
await evaluate('sms',"var input=document.getElementById('vulpes-keypad-probe');input.value='';input.type='text';input.focus();return true;");
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:++sequence,keys:'2266622'},{tmpPath:'/work/keypad-request.json.tmp'});
report.multitap=await until(()=>evaluate('sms',"return document.getElementById('vulpes-keypad-probe').value==='bob';"));
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:++sequence,keys:'#2#2'},{tmpPath:'/work/keypad-request.json.tmp'});
report.inputModes=await until(()=>evaluate('sms',"return document.getElementById('vulpes-keypad-probe').value==='bobA2';"));
await key('Backspace');
report.deleteText=await until(()=>evaluate('sms',"return document.getElementById('vulpes-keypad-probe').value==='bobA';"));
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:++sequence,keys:'#'},{tmpPath:'/work/keypad-request.json.tmp'});
await until(()=>evaluate('sms',"return document.getElementById('vulpes-keypad-mode')?.textContent.startsWith('abc');"));
await evaluate('sms',"document.getElementById('vulpes-keypad-probe').remove();document.body.focus();return true;");
// Use the original SMS composer as well as the isolated editor probe.
await evaluate('sms',"var button=document.getElementById('threads-composer-link');button.focus();return true;");
await key('Enter');
if(!await until(()=>evaluate('sms',"return typeof ConversationView!=='undefined' && Navigation.isCurrentPanel('composer') && document.activeElement.classList.contains('recipient');")))throw Error('SMS composer did not open');
await evaluate('sms',"await App.whenReady();await new Promise(r=>setTimeout(r,400));ConversationView.recipients.focus();return true;");
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:++sequence,keys:'5550100'},{tmpPath:'/work/keypad-request.json.tmp'});
if(!await until(()=>evaluate('sms',"return document.querySelector('#messages-recipients-list [contenteditable]')?.textContent==='5550100';")))throw Error('Recipient digits did not reach the original form');
await key('Enter');
report.smsRecipient=await until(()=>evaluate('sms',"return ConversationView.recipients.numbers.includes('5550100');"));
await evaluate('sms',"Compose.focus();return true;");
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:++sequence,keys:'2266622'},{tmpPath:'/work/keypad-request.json.tmp'});
report.smsBody=await until(()=>evaluate('sms',"return document.getElementById('messages-input').textContent.trim()==='bob';"));
// Never send the synthetic message or retain it as a draft.
await evaluate('sms',"ConversationView.cleanFields();return true;");
await key('Escape');
await until(()=>evaluate('sms',"return !Navigation.isCurrentPanel('composer');"));
await key('Escape');
report.returnHome=await until(()=>evaluate('system',"return [...document.querySelectorAll('iframe')].some(f=>f.src.includes('homescreen.localhost')&&f.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}));"));
}
if(report.returnHome) {
await evaluate('homescreen',"var icon=[...document.querySelectorAll('gaia-app-icon')].find(e=>e.dataset.identifier.includes('communications'));icon.focus();return true;");
await key('Enter');
report.dialerOpened=await until(()=>Promise.resolve(!!find(system,'communications')));
if(report.dialerOpened) {
if(!await until(()=>evaluate('communications',"return performance.getEntriesByName('contentInteractive').length>0 && document.getElementById('phone-number-view')?.checkVisibility({checkOpacity:true,checkVisibilityCSS:true});")))throw Error('Dialer keypad did not become interactive');
await until(()=>evaluate('system',"return [...document.querySelectorAll('iframe')].some(f=>f.src.includes('communications.localhost')&&f.checkVisibility({checkOpacity:true,checkVisibilityCSS:true}));"));
await IOUtils.writeJSON('/work/keypad-request.json',{sequence:++sequence,keys:'5550100'},{tmpPath:'/work/keypad-request.json.tmp'});
report.dialerDigits=await until(()=>evaluate('communications',"return document.getElementById('phone-number-view')?.value==='5550100';"));
report.dialerObserved=await evaluate('communications',"return document.getElementById('phone-number-view')?.value;");
for(let i=0;i<7;i++){await key('Backspace');await sleep(150);}
report.dialerClear=await until(()=>evaluate('communications',"return document.getElementById('phone-number-view')?.value==='';"));
await key('Escape');
}
}
}catch(error){report.error=String(error);report.stack=error.stack;}
await save();
}

View file

@ -0,0 +1,137 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
import json
import os
import re
from pathlib import Path
import shutil
import subprocess
import time
os.umask(0o022)
for name in ('gaia-audit.json', 'gaia-native.png', 'gecko.log', 'gaia-server.log', 'memory-samples.jsonl', 'keypad-request.json', 'keypad-response.json', 'manual-request.json', 'manual-response.json'):
Path('/work',name).unlink(missing_ok=True)
ROOT = Path('/opt/vulpes')
PROFILE = Path('/work/gaia-profile')
PROFILE.mkdir(exist_ok=True)
os.chown(PROFILE,2000,2000)
prefs = {
'browser.shell.checkDefaultBrowser': False,
'browser.aboutwelcome.enabled': False,
'browser.startup.homepage_override.mstone': 'ignore',
'browser.dom.window.dump.enabled': True,
'datareporting.policy.dataSubmissionEnabled': False,
'app.update.auto': False,
'fission.autostart': False,
'dom.ipc.processCount': 1,
'dom.ipc.processPrelaunch.enabled': False,
'gfx.webrender.software': True,
'layers.acceleration.disabled': True,
'layout.css.devPixelsPerPx': '1.0',
'browser.cache.disk.enable': False,
'browser.cache.memory.capacity': 8192,
}
(PROFILE/'user.js').write_text('\n'.join('user_pref(%s,%s);'%(json.dumps(k),json.dumps(v)) for k,v in prefs.items()))
os.chown(PROFILE/'user.js',2000,2000)
os.chmod(PROFILE/'user.js',0o600)
saved = PROFILE/'vulpes-settings-v1.json'
if not saved.exists():
saved.write_text(json.dumps({'lockscreen.enabled':False,'vulpes.gaia-restoration.v1':True,'language.current':'fr'}))
os.chown(saved,2000,2000)
shutil.copy2(ROOT/'host/bootstrap/vulpes-autoconfig.js','/opt/firefox/defaults/pref/vulpes-autoconfig.js')
config=(ROOT/'host/bootstrap/vulpes.cfg').read_text()
config+="\nChromeUtils.importESModule('resource://vulpes/tests/BeatlesAudit.sys.mjs').install();\n"
Path('/opt/firefox/vulpes.cfg').write_text(config)
os.chmod('/opt/firefox/vulpes.cfg',0o644)
for directory in ('logs','profiles'):
(ROOT/directory).mkdir(exist_ok=True)
os.chown(ROOT/directory,2000,2000)
for path in (ROOT/'assets').iterdir():
if path.is_file():os.chown(path,2000,2000)
def unprivileged():
Path('/proc/self/oom_score_adj').write_text('800')
# Android's paranoid-network kernel requires inet even for private loopback.
os.setgroups([3003])
os.setgid(2000)
os.setuid(2000)
env=os.environ.copy()
env.update(HOME='/work',TMPDIR='/tmp',DISPLAY=':0',MOZ_ENABLE_WAYLAND='0',
VULPES_HOST_ROOT='/opt/firefox/vulpes/host',VULPES_WORKSPACE_ROOT='/opt/vulpes',
VULPES_FULLSCREEN='1',VULPES_KEYPAD='1',VULPES_NATIVE_TOUCH='1',
VULPES_DEVICE='beatles',VULPES_TUNDRA_STATUS='1',
MOZ_CRASHREPORTER_DISABLE='1',FONTCONFIG_PATH='/etc/fonts')
duration=1780 if os.environ.get("VULPES_TRIAL_MANUAL")=="1" else 160
processes=[]
handles=[]
try:
def start(command,log,drop=False):
path=Path('/work/'+log)
output=path.open('w');os.chmod(path,0o644);handles.append(output)
proc=subprocess.Popen(command,env=env,stdout=output,stderr=subprocess.STDOUT,
preexec_fn=unprivileged if drop else None)
processes.append(proc)
return proc
xorg=start(['/usr/lib/xorg/Xorg',':0','vt1','-config','/etc/X11/xorg.conf',
'-logfile','/work/Xorg.log','-nolisten','tcp','-ac','-noreset','-novtswitch','-sharevts','-nocursor'],'xorg-console.log')
for _ in range(100):
if xorg.poll() is not None:raise RuntimeError('Xorg failed')
if Path('/tmp/.X11-unix/X0').exists():break
time.sleep(.1)
keypad=start(['/work/keypad'],'keypad.log')
for _ in range(120):
if keypad.poll() is not None:raise RuntimeError('Physical keypad bridge failed; inspect keypad.log')
if 'Nokia keypad bridge ready' in Path('/work/keypad.log').read_text():break
time.sleep(.1)
else:raise RuntimeError('Physical keypad bridge startup timed out')
cover=start(['/usr/bin/python3.11','/work/cover-display.py'],'cover-display.log')
for _ in range(50):
if cover.poll() is not None:raise RuntimeError('External display failed; inspect cover-display.log')
if Path('/work/cover-ready').exists():break
time.sleep(.1)
else:raise RuntimeError('External display startup timed out')
server=start(['/usr/bin/python3.11',str(ROOT/'tools/serve-gaia.py')],'gaia-server.log',True)
import socket
for _ in range(100):
if server.poll() is not None:raise RuntimeError('Gaia server failed')
try:
with socket.create_connection(('127.0.0.1',8765),timeout=.2):break
except OSError:time.sleep(.1)
gecko=start(['/opt/firefox/firefox','-no-remote','-purgecaches','-profile',str(PROFILE),
'-chrome','chrome://vulpes/content/shell.xhtml'],'gecko.log',True)
begin=time.monotonic()
last_key_sequence=0
last_memory_sample=0
with Path('/work/memory-samples.jsonl').open('w') as memory:
while gecko.poll() is None and time.monotonic()-begin<duration:
if cover.poll() is not None:raise RuntimeError('External display stopped')
if keypad.poll() is not None:raise RuntimeError('Physical keypad bridge stopped')
request=Path('/work/keypad-request.json')
if request.exists():
command=json.loads(request.read_text())
sequence=command.get('sequence');processed=False
if (isinstance(sequence,int) and last_key_sequence<sequence<=64 and
command.get('key') in ('ArrowRight','ArrowLeft','ArrowUp','ArrowDown','Backspace','Enter','2','Escape','EndCall','Notifications','AudioVolumeUp','AudioVolumeDown')):
subprocess.run(['/work/keypad','--inject-key',command['key']],env=env,check=True,timeout=5)
last_key_sequence=sequence;processed=True
elif (isinstance(sequence,int) and last_key_sequence<sequence<=64 and
isinstance(command.get('keys'),str) and re.fullmatch(r'[0-9*#]{1,40}',command['keys'])):
subprocess.run(['/work/keypad','--inject-keys',command['keys']],env=env,check=True,timeout=10)
last_key_sequence=sequence;processed=True
if processed:
reply=Path('/work/keypad-response.tmp');reply.write_text(json.dumps({'sequence':sequence}));reply.replace('/work/keypad-response.json')
sample={'seconds':round(time.monotonic()-begin,1),'meminfo':Path('/proc/meminfo').read_text()}
if time.monotonic()-last_memory_sample>=2:
memory.write(json.dumps(sample)+'\n');memory.flush();last_memory_sample=time.monotonic()
time.sleep(.1)
print('Gecko exit:',gecko.poll(),flush=True)
finally:
for proc in reversed(processes):
if proc.poll() is None:proc.terminate()
for proc in reversed(processes):
try:proc.wait(timeout=3)
except subprocess.TimeoutExpired:proc.kill();proc.wait()
for handle in handles:handle.close()

View file

@ -0,0 +1 @@
<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Vulpes ARMv7 Gecko test</title><style>*{box-sizing:border-box}body{margin:0;background:#071b2c;color:#edf6ff;font:16px sans-serif;padding:18px;width:240px}h1{font-size:24px;margin:4px 0 20px}small{color:#8ac9ff}.cards{display:grid;gap:12px}.card{padding:12px;background:#153347;border-left:3px solid #57db9d}p{font-size:13px;line-height:1.4}</style><small>VULPES · LABORATOIRE</small><h1>Gecko ARMv7</h1><div class="cards"><div class="card" id="engine">Chargement…</div><div class="card" id="js">JavaScript…</div></div><p>Page calculée sur le Nokia.<br>Essai hors écran, pas encore une interface interactive.</p><script>document.getElementById('engine').textContent='Gecko '+navigator.userAgent.match(/rv:([\d.]+)/)[1];const value={test:{ok:true}};document.getElementById('js').textContent=value?.test?.ok && 1n+2n===3n?'JavaScript moderne : OK':'Échec';</script>

View file

@ -0,0 +1,51 @@
# SPDX-License-Identifier: MPL-2.0
"""Fixed Nokia controls mounted writable only in the trusted broker namespace."""
import math
from pathlib import Path
import time
class HardwareControl:
def __init__(self,root=Path('/work/hardware-controls')):
self.root=root;self.original=None;self.pattern=[];self.next_at=0;self.on=False
def write(self,name,value):
(self.root/name).write_text(str(value)+'\n')
def request(self,value):
if not isinstance(value,dict):raise ValueError('INVALID_REQUEST')
op=value.get('operation')
if op=='screen':
level=value.get('level');enabled=value.get('enabled')
if type(enabled) is not bool or type(level) not in (int,float) or not math.isfinite(level) or not 0<=level<=1:
raise ValueError('INVALID_REQUEST')
maximum=int((self.root/'backlight-max').read_text())
if not 1<=maximum<=65535:raise RuntimeError('NOT_SUPPORTED')
if self.original is None:self.original=int((self.root/'backlight').read_text())
brightness=max(1,int(level*maximum+.5)) if enabled else 0
self.write('backlight',brightness)
actual=int((self.root/'backlight').read_text())
if actual!=brightness:raise RuntimeError('HARDWARE_READBACK_FAILED')
return {'brightness':actual,'maximum':maximum}
if op=='vibrate':
pattern=value.get('pattern')
if not isinstance(pattern,list) or len(pattern)>32 or any(type(n) is not int or not 0<=n<=10000 for n in pattern) or sum(pattern)>10000:
raise ValueError('INVALID_REQUEST')
self.write('vibrator',0)
self.pattern=list(pattern);self.on=False;self.next_at=0;self.tick()
return True
raise ValueError('NOT_SUPPORTED')
def tick(self):
now=time.monotonic()
while self.pattern and now>=self.next_at:
duration=self.pattern.pop(0);self.on=not self.on
self.write('vibrator',duration if self.on else 0)
self.next_at=now+duration/1000
def restore(self):
self.pattern=[]
for name,value in [('vibrator',0),('backlight',self.original)]:
if value is not None:
try:self.write(name,value)
except OSError:pass

View file

@ -0,0 +1,331 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Nokia RIL control broker. Socket clients are Gecko's uid, never web content.
Parcel layouts follow Gecko 52 dom/system/gonk/ril_worker.js. The vendor
socket stays in Android's network namespace; Gecko only sees this broker.
"""
import copy
import hashlib
import json
import os
from pathlib import Path
import select
import signal
import socket
import struct
import subprocess
import sys
import time
import uuid
from call_audio import CallAudio
from stream_volume import StreamVolume
from hardware_control import HardwareControl
from radio_status import Radio, battery, REQUESTS
from sms_pdu import submit, deliver, number
# Fixed allowlist excludes every SIM PIN/PUK, identity and provisioning request.
CONTROL_REQUESTS={9,10,12,23,25,37,40}
SOCKET='/work/phone-control.sock'
STORE=Path('/work/phone-private/inbox.json')
def integer(*values):return struct.pack('<'+'i'*len(values),*values)
def string(value):
if value is None:return integer(-1)
raw=value.encode('utf-16-le');body=integer(len(raw)//2)+raw+b'\0\0'
return body+b'\0'*((-len(body))%4)
def current_calls(parcel):
count=parcel.integer() if parcel.offset<len(parcel.data) else 0
if not 0<=count<=16:raise ValueError('INVALID_CALL_LIST')
rows=[]
for _ in range(count):
state,index,toa,multi,incoming,als,voice,privacy=[parcel.integer() for _ in range(8)]
num=parcel.string() or '';presentation=parcel.integer()
parcel.string();parcel.integer();uus=parcel.integer()
if uus:raise ValueError('UNSUPPORTED_CALL_UUS')
if state not in range(6) or not 1<=index<=255:raise ValueError('INVALID_CALL_LIST')
if voice:
if presentation!=0:num=''
elif toa==145 and num and not num.startswith('+'):num='+'+num
rows.append({'index':index,'state':('active','held','dialing','alerting','incoming','waiting')[state],
'number':num[:80],'direction':'incoming' if incoming else 'outgoing'})
return rows
class Inbox:
def __init__(self,path=STORE):
self.path=path
self.value=json.loads(path.read_text()) if path.exists() else {'inbox':[],'parts':{},'seen':{}}
def save(self):
self.path.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
self.path.parent.chmod(0o700)
temporary=self.path.with_suffix('.tmp')
with open(temporary,'w',opener=lambda p,f:os.open(p,f,0o600)) as stream:
os.fchmod(stream.fileno(),0o600);json.dump(self.value,stream)
stream.flush();os.fsync(stream.fileno())
temporary.replace(self.path)
fd=os.open(self.path.parent,os.O_DIRECTORY)
try:os.fsync(fd)
finally:os.close(fd)
def receive(self,pdu):
previous=copy.deepcopy(self.value)
try:self._receive(pdu)
except Exception:
self.value=previous;raise
def _receive(self,pdu):
row=deliver(pdu);key=hashlib.sha256(pdu.upper().encode()).hexdigest();now=time.time()
if key in self.value['seen']:return
if len(self.value['inbox'])>=500:raise ValueError('SMS_STORE_FULL')
self.value['seen']={k:v for k,v in self.value['seen'].items() if now-v<7*86400}
self.value['parts']={k:v for k,v in self.value['parts'].items() if now-v['created']<86400}
concat=row.pop('concat')
if concat:
ref,total,part=concat
group=hashlib.sha256((row['sender']+':'+str(ref)+':'+str(total)).encode()).hexdigest()
if group not in self.value['parts'] and len(self.value['parts'])>=100:raise ValueError('SMS_STORE_FULL')
record=self.value['parts'].setdefault(group,{'created':now,'pieces':{}})
record['pieces'][str(part)]=row['body']
if len(record['pieces'])==total:
row['body']=''.join(record['pieces'][str(i)] for i in range(1,total+1))
row['receipt']=str(uuid.uuid4());self.value['inbox'].append(row)
del self.value['parts'][group]
else:
row['receipt']=str(uuid.uuid4());self.value['inbox'].append(row)
self.value['seen'][key]=now;self.save()
def acknowledge(self,ids):
if not isinstance(ids,list) or len(ids)>100 or any(not isinstance(i,str) or len(i)!=36 for i in ids):raise ValueError('INVALID_REQUEST')
previous=copy.deepcopy(self.value)
try:
self.value['inbox']=[r for r in self.value['inbox'] if r['receipt'] not in ids];self.save()
except Exception:
self.value=previous;raise
return True
class ControlRadio(Radio):
def __init__(self,path,slot,inbox):
super().__init__(path,slot);self.inbox=inbox;self.result=None;self.waiting=None
def send(self,request,payload=b''):
if request not in CONTROL_REQUESTS:raise ValueError('RADIO_INVALID_REQUEST')
if not self.sock:raise RuntimeError('RADIO_NOT_READY')
self.serial+=1;body=integer(request,self.serial)+payload
self.sock.sendall(struct.pack('>I',len(body))+body)
self.pending[self.serial]=(request,time.monotonic())
return self.serial
def response(self,request,error,parcel,serial=None):
if request in REQUESTS:return super().response(request,error,parcel)
if request==37:return
if (request,serial)!=self.waiting:return
if error:self.result=(False,'RADIO_REQUEST_FAILED');return
if request==9:
try:result=current_calls(parcel)
except (ValueError,UnicodeError) as error:
print('Call list decode:',str(error),'offset',parcel.offset,'length',len(parcel.data),flush=True)
self.result=(False,'RADIO_INVALID_CALL_LIST');return
elif request==25:
parcel.integer();parcel.string();parcel.integer()
result={'state':'sent'}
else:result=True
self.result=(True,result)
def unsolicited(self,event,parcel):
if event==1003 and self.slot==0:
try:
self.inbox.receive(parcel.string());success,cause=1,0
except (OSError,ValueError,UnicodeError):
# Do not acknowledge success until the message is durably stored.
print('Incoming SMS not stored',flush=True);success,cause=0,255
self.send(37,integer(2,success,cause))
else:super().unsolicited(event,parcel)
class Broker:
def __init__(self,radios,inbox,audio=None,volume=None,hardware=None):
self.radios=radios;self.inbox=inbox;self.session=uuid.uuid4().hex;self.published=0;self.audio=audio;self.volume=volume;self.hardware=hardware;self.original_power={}
def step(self,timeout=.1):
for radio in self.radios:
try:radio.poll()
except (OSError,ValueError):radio.close()
sockets={r.sock:r for r in self.radios if r.sock}
ready,_,_=select.select(list(sockets),[],[],timeout)
for sock in ready:
try:sockets[sock].receive()
except (OSError,ValueError,UnicodeError) as error:
print('RIL receive:',type(error).__name__,str(error),flush=True);sockets[sock].close()
if time.monotonic()-self.published>=1:
dest=Path('/run/tundra-hardware/status.json');dest.parent.mkdir(exist_ok=True,parents=True)
try:wifi=json.loads(Path('/run/tundra-wifi/state.json').read_text())
except (OSError,ValueError):wifi={'available':False,'state':'unavailable'}
value={'schema':1,'recordedAt':time.time(),'battery':battery(),
'wifi':wifi,'radio':[r.state for r in self.radios]}
temp=dest.with_suffix('.tmp');temp.write_text(json.dumps(value));temp.chmod(0o644);temp.replace(dest)
self.published=time.monotonic()
def exchange(self,request,payload=b'',timeout=12,slot=0):
radio=self.radios[slot]
# A UI timeout does not cancel the request in the vendor daemon. Do not
# enqueue more copies while waiting for its reply or transport recovery.
if any(kind==request for kind,_ in radio.pending.values()):
raise RuntimeError('RADIO_BUSY')
if request==9:timeout=min(timeout,3)
radio.result=None;radio.waiting=None
try:
radio.waiting=(request,radio.send(request,payload));until=time.monotonic()+timeout
while radio.result is None and time.monotonic()<until:
self.step()
if not radio.sock:raise RuntimeError('RADIO_NOT_READY')
if radio.result is None:raise RuntimeError('RADIO_TIMEOUT')
success,value=radio.result
if not success:raise RuntimeError(value)
return value
finally:radio.waiting=None
def radio_power(self,slot,enabled,remember=True):
if type(slot) is not int or slot not in range(len(self.radios)) or type(enabled) is not bool:
raise ValueError('INVALID_REQUEST')
radio=self.radios[slot]
before=radio.state.get('radioState')
if before not in ('enabled','disabled'):raise RuntimeError('RADIO_NOT_READY')
target='enabled' if enabled else 'disabled'
if before==target:return {'slot':slot,'radioState':target}
# Do not interrupt an ongoing or incoming call when changing radio power.
if not enabled and self.radios[0].state.get('radioState')=='enabled' and self.calls():
raise RuntimeError('RADIO_BUSY')
if remember:self.original_power.setdefault(slot,before=='enabled')
self.exchange(23,integer(1,int(enabled)),slot=slot)
deadline=time.monotonic()+8
while radio.state.get('radioState')!=target and time.monotonic()<deadline:
self.step()
if not radio.sock:raise RuntimeError('RADIO_NOT_READY')
if radio.state.get('radioState')!=target:raise RuntimeError('RADIO_READBACK_FAILED')
radio.last_poll=0
return {'slot':slot,'radioState':target}
def restore_power(self):
for slot,enabled in self.original_power.items():
try:self.radio_power(slot,enabled,remember=False)
except (OSError,RuntimeError,ValueError) as error:
print('Radio power restore failed:',slot,str(error),flush=True)
def calls(self):
rows=[{**row,'path':'/beatles/'+self.session+'/call/'+str(row['index'])} for row in self.exchange(9)]
if self.audio:self.audio.update(rows)
return rows
def request(self,value):
if not isinstance(value,dict) or value.get('version')!=1:raise ValueError('INVALID_REQUEST')
op=value.get('operation')
if op=='status':return {'ready':self.radios[0].state.get('available',False),'voiceAudio':bool(self.audio and self.audio.available)}
if op=='radioPower':return self.radio_power(value.get('slot'),value.get('enabled'))
if op=='hardware':
if not self.hardware:raise RuntimeError('NOT_SUPPORTED')
return self.hardware.request(value.get('settings'))
if op=='volumeState':
if not self.volume:raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
return self.volume.state()
if op=='volume':
if not self.volume:raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
return self.volume.request(value)
if op=='callAudio':
if not self.audio:raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
return self.audio.request(value.get('settings',{}))
if op=='incomingMessages':return self.inbox.value['inbox'][:10]
if op=='ackMessages':return self.inbox.acknowledge(value.get('ids'))
if op=='calls':return self.calls()
if op in ('dial','sms'):
destination=number(value.get('number'))
if not self.radios[0].state.get('voice',{}).get('connected'):raise RuntimeError('RADIO_NOT_REGISTERED')
if op=='sms':return self.exchange(25,integer(2)+string(None)+string(submit(destination,value.get('body'))))
before={row['path'] for row in self.calls()}
if before:raise RuntimeError('RADIO_BUSY')
self.exchange(10,string(destination)+integer(0,0,0))
for _ in range(30):
for row in self.calls():
if row['path'] not in before and row['direction']=='outgoing':return {'path':row['path'],'startedAt':time.time()}
self.step(.1)
raise RuntimeError('RADIO_TIMEOUT')
if op in ('hangup','answer'):
path=value.get('path');call=next((r for r in self.calls() if r['path']==path),None)
if not call:raise ValueError('INVALID_CALL')
if op=='answer':
if call['state'] not in ('incoming','waiting'):raise ValueError('INVALID_CALL')
self.exchange(40)
else:self.exchange(12,integer(1,call['index']))
return {'path':path}
raise ValueError('NOT_SUPPORTED')
def read_request(conn):
data=b''
while b'\n' not in data:
chunk=conn.recv(4096)
if not chunk:break
data+=chunk
if len(data)>8192:raise ValueError('INVALID_REQUEST')
return json.loads(data)
def serve():
os.umask(0o022);inbox=Inbox()
radios=[ControlRadio(os.environ.get('VULPES_RIL_SOCKET_DIR','/dev/socket')+'/'+name,i,inbox) for i,name in enumerate(('rild','rild2'))]
audio=CallAudio();volume=StreamVolume();hardware=HardwareControl();broker=Broker(radios,inbox,audio,volume,hardware);running=True
def stop(*_):
nonlocal running
running=False
signal.signal(signal.SIGTERM,stop)
wifi_log=open('/work/wifi-control.log','w')
wifi_process=subprocess.Popen([sys.executable,'/work/wifi_control.py'],stdout=wifi_log,stderr=wifi_log)
path=Path(SOCKET);path.unlink(missing_ok=True)
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as server:
server.bind(SOCKET);os.chown(SOCKET,0,2000);os.chmod(SOCKET,0o660);server.listen(8);server.setblocking(False)
print('Nokia phone broker ready; slot 0; Android 6 audio adapter',flush=True)
try:
while running:
broker.step()
try:hardware.tick()
except OSError:
hardware.pattern=[]
print('Nokia vibration control unavailable',flush=True)
if not select.select([server],[],[],0)[0]:continue
conn,_=server.accept()
with conn:
conn.settimeout(2)
pid,uid,gid=struct.unpack('3i',conn.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12))
if uid not in (0,2000):continue
try:response={'ok':True,'result':broker.request(read_request(conn))}
except (ValueError,RuntimeError,OSError,subprocess.SubprocessError) as error:
allowed={'INVALID_REQUEST','INVALID_NUMBER','INVALID_SMS','SMS_TOO_LONG','INVALID_CALL','RADIO_NOT_REGISTERED','RADIO_NOT_READY','RADIO_BUSY','RADIO_REQUEST_FAILED','RADIO_READBACK_FAILED','RADIO_INVALID_CALL_LIST','RADIO_TIMEOUT','NOT_SUPPORTED','CALL_AUDIO_UNAVAILABLE','HARDWARE_READBACK_FAILED'}
message=str(error);response={'ok':False,'error':message if message in allowed else 'RADIO_REQUEST_FAILED'}
try:conn.sendall(json.dumps(response).encode()+b'\n')
except OSError:pass
finally:
path.unlink(missing_ok=True)
broker.restore_power()
wifi_process.terminate()
try:wifi_process.wait(timeout=5)
except subprocess.TimeoutExpired:wifi_process.kill();wifi_process.wait()
wifi_log.close()
hardware.restore()
volume.restore()
audio.restore()
for radio in radios:radio.close()
def client():
raw=sys.stdin.buffer.read(8193)
if len(raw)>8192:raise ValueError('INVALID_REQUEST')
value=json.loads(raw)
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as conn:
conn.settimeout(25);conn.connect(SOCKET)
conn.sendall(json.dumps(value).encode()+b'\n')
response=b''
while b'\n' not in response:
chunk=conn.recv(8192)
if not chunk:raise RuntimeError('RADIO_INVALID_REPLY')
response+=chunk
if len(response)>65536:raise RuntimeError('RADIO_INVALID_REPLY')
sys.stdout.write(response.decode())
if __name__=='__main__':
if sys.argv[1:]==['--client-envelope']:client()
elif not sys.argv[1:]:serve()
else:raise SystemExit('Unsupported arguments')

View file

@ -0,0 +1,201 @@
# SPDX-License-Identifier: MPL-2.0
"""Read-only Android RIL status for beatles; no PIN, identity, SMS or call requests."""
import json
import os
from pathlib import Path
import select
import socket
import struct
import time
REQUESTS = (1, 19, 20, 21, 22) # SIM status, signal, voice/data registration, operator.
TECHNOLOGIES = {1:'gprs',2:'edge',3:'umts',4:'is95a',5:'is95b',6:'1xrtt',
7:'evdo0',8:'evdoa',9:'hsdpa',10:'hsupa',11:'hspa',12:'evdob',
13:'ehrpd',14:'lte',15:'hspa+',16:'gsm'}
class Parcel:
def __init__(self, data):
self.data, self.offset = data, 0
def integer(self):
if self.offset + 4 > len(self.data): raise ValueError('TRUNCATED_PARCEL')
value, = struct.unpack_from('<i', self.data, self.offset)
self.offset += 4
return value
def string(self):
length = self.integer()
if length == -1: return None
if not 0 <= length <= 4096: raise ValueError('INVALID_STRING_LENGTH')
end = self.offset + length * 2
if end + 2 > len(self.data): raise ValueError('TRUNCATED_STRING')
if self.data[end:end+2] != b'\0\0': raise ValueError('INVALID_TERMINATOR')
value = self.data[self.offset:end].decode('utf-16-le')
self.offset = (end + 2 + 3) & ~3
if self.offset > len(self.data): raise ValueError('TRUNCATED_PADDING')
return value
def strings(self):
count = self.integer()
if not 0 <= count <= 32: raise ValueError('INVALID_ARRAY_LENGTH')
return [self.string() for _ in range(count)]
def registration(values):
state = int(values[0]) if values and values[0] else -1
tech = int(values[3]) if len(values)>3 and values[3] else 0
return {'state':{0:'notSearching',1:'registered',2:'searching',3:'denied',
4:'unknown',5:'registered',10:'notSearching',12:'searching',
13:'denied',14:'unknown'}.get(state,'unknown'),
'connected':state in (1,5), 'roaming':state == 5,
'emergencyCallsOnly':state in (10,12,13,14), 'type':TECHNOLOGIES.get(tech)}
def decode(request, parcel):
if request == 1:
card = parcel.integer(); parcel.integer()
gsm = parcel.integer(); parcel.integer(); parcel.integer()
count = parcel.integer()
if not 0 <= count <= 8: raise ValueError('INVALID_SIM_APPLICATION_COUNT')
app_state = None
for index in range(count):
parcel.integer(); state=parcel.integer(); parcel.integer()
parcel.string(); parcel.string() # AID/label are deliberately discarded.
parcel.integer(); parcel.integer(); parcel.integer()
if index == gsm: app_state=state
return {'sim':{'present':card == 1, 'cardState':
'absent' if card == 0 else {2:'pinRequired',3:'pukRequired',4:'networkLocked',5:'ready'}.get(app_state,'unknown')}}
if request in (20,21):
state=registration(parcel.strings())
# Packet-network registration does not prove that a data bearer is open.
if request == 21: state['connected']=False
return {('voice' if request == 20 else 'data'):state}
if request == 22:
values = parcel.strings()
return {'operator':{'longName':(values[0] or '')[:100] if values else '',
'shortName':(values[1] or '')[:100] if len(values)>1 else ''}}
if request == 19:
# GSM ASU can be unknown on LTE. RIL v6+ stores positive LTE RSRP
# at index 8; normalize its valid 44..140 dBm magnitude for Gaia.
values=[]
while parcel.offset + 4 <= len(parcel.data): values.append(parcel.integer())
if not values: raise ValueError('TRUNCATED_SIGNAL')
asu=values[0]
rsrp=values[8] if len(values)>8 else None
if rsrp is not None and 44<=rsrp<=140:
return {'signal':round((140-rsrp)*100/96)}
return {'signal':round(asu*100/31) if 0<=asu<=31 else None}
raise ValueError('READ_ONLY_REQUEST_REQUIRED')
class Radio:
def __init__(self, path, slot):
self.path,self.slot=path,slot
self.sock=None;self.buffer=b'';self.pending={};self.serial=0
self.last_poll=0;self.retry_at=0;self.retry_delay=5
self.state={'slot':slot,'available':False,'radioState':'unknown'}
def close(self):
if self.sock:self.sock.close()
self.sock=None;self.buffer=b'';self.pending={}
self.state={'slot':self.slot,'available':False,'radioState':'unknown'}
self.retry_at=time.monotonic()+self.retry_delay
self.retry_delay=min(60,self.retry_delay*2)
def connect(self):
connection=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM)
try:
connection.settimeout(1);connection.connect(self.path)
connection.setblocking(False);self.sock=connection;self.last_poll=0
print('RIL slot',self.slot,'connected',flush=True)
except OSError as error:
print('RIL slot',self.slot,'connect errno',error.errno,flush=True)
connection.close();self.close()
def poll(self):
now=time.monotonic()
if not self.sock:
if now>=self.retry_at:self.connect()
return
# CS fallback and network handover can outlast an individual UI request.
# Keep the transport open for late replies instead of repeatedly filling
# the vendor socket's accept queue with abandoned reconnects.
if any(now-created>45 for _,created in self.pending.values()):
print('RIL slot',self.slot,'request timeout; kinds',sorted({kind for kind,_ in self.pending.values()}),flush=True)
self.close();return
if now-self.last_poll<5:return
self.last_poll=now
for request in REQUESTS:
if any(kind==request for kind,_ in self.pending.values()):continue
self.serial+=1
body=struct.pack('<ii',request,self.serial)
self.sock.sendall(struct.pack('>I',len(body))+body)
self.pending[self.serial]=(request,now)
def receive(self):
data=self.sock.recv(65536)
if not data:raise OSError('RIL_DISCONNECTED')
self.buffer+=data
while len(self.buffer)>=4:
length,=struct.unpack_from('>I',self.buffer)
if not 4<=length<=65536:raise ValueError('INVALID_FRAME_LENGTH')
if len(self.buffer)<length+4:return
parcel=Parcel(self.buffer[4:4+length]);self.buffer=self.buffer[4+length:]
kind=parcel.integer()
if kind == 0:
serial,error=parcel.integer(),parcel.integer()
pending=self.pending.pop(serial,None)
if pending is None:continue
request,_=pending
self.retry_delay=5
self.response(request,error,parcel,serial)
elif kind == 1:
self.unsolicited(parcel.integer(),parcel)
def response(self,request,error,parcel,serial=None):
self.state['available']=True
if error:
self.state.setdefault('errors',{})[str(request)]=error
self.state.pop({1:'sim',19:'signal',20:'voice',21:'data',22:'operator'}[request],None)
return
self.state.setdefault('errors',{}).pop(str(request),None)
self.state.update(decode(request,parcel))
def unsolicited(self,event,parcel):
if event == 1000:
self.state['radioState']={0:'disabled',1:'unavailable',10:'enabled'}.get(parcel.integer(),'unknown')
elif event == 1034:
count=parcel.integer()
if count==1:self.state['rilVersion']=parcel.integer()
elif event in (1002,1009,1019):self.last_poll=0
# The read-only adapter never consumes or acknowledges incoming SMS.
def battery(root=Path('/sys/class/power_supply/battery')):
try:
level=int((root/'capacity').read_text().strip())
status=(root/'status').read_text().strip()
if not 0<=level<=100 or status not in ('Charging','Full','Discharging','Not charging'):return None
return {'level':level/100,'charging':status in ('Charging','Full')}
except (OSError,ValueError):return None
def main():
os.umask(0o022)
destination=Path('/run/tundra-hardware/status.json')
destination.parent.mkdir(parents=True,exist_ok=True)
destination.parent.chmod(0o755)
radios=[Radio(os.environ.get('VULPES_RIL_SOCKET_DIR','/dev/socket')+'/'+name,index) for index,name in enumerate(('rild','rild2'))]
published=0
while True:
for radio in radios:
try:radio.poll()
except (OSError,ValueError) as error:
print('RIL slot',radio.slot,'poll',type(error).__name__,getattr(error,'errno',None),flush=True);radio.close()
active={r.sock:r for r in radios if r.sock}
readable,_,_=select.select(list(active),[],[],.25)
for sock in readable:
try:active[sock].receive()
except (OSError,ValueError,UnicodeError) as error:
print('RIL slot',active[sock].slot,'receive',type(error).__name__,getattr(error,'errno',None),flush=True);active[sock].close()
if time.monotonic()-published<1:continue
published=time.monotonic()
value={'schema':1,'recordedAt':time.time(),'battery':battery(),
'wifi':{'available':False,'state':'unavailable'},'radio':[r.state for r in radios]}
temporary=destination.with_suffix('.tmp')
temporary.write_text(json.dumps(value)+'\n');temporary.chmod(0o644);temporary.replace(destination)
if __name__=='__main__':main()

View file

@ -0,0 +1,16 @@
#!/system/bin/sh
BASE=/data/local/tmp/vulpes-gecko-armhf
case "$2" in 156.0.1) BASE=/data/local/vulpes-beatles;; ""|153.5.0esr) ;; *) exit 2;; esac
MODE="$1"
case "$MODE" in version|render) ;; *) exit 2;; esac
[ "$MODE" != render ] || /system/bin/busybox rm -f "$BASE/rootfs/work/gecko.png"
/system/bin/busybox mkdir -p "$BASE/rootfs/proc" "$BASE/rootfs/dev/shm" "$BASE/rootfs/work/profile" "$BASE/rootfs/tmp"
/system/bin/busybox chown -R 2000:2000 "$BASE/rootfs/work" "$BASE/rootfs/tmp"
/system/bin/busybox timeout -t 60 -s KILL "$BASE/gecko-probe-launcher" "$MODE" > "$BASE/$MODE.log" 2>&1
CODE=$?
# Only the disposable runtime's processes; KaiOS and ADB use another root.
for PROC in /proc/[0-9]*; do
[ "$(/system/bin/busybox readlink "$PROC/root")" != "$BASE/rootfs" ] || kill -KILL "${PROC##*/}"
done
echo "$CODE" > "$BASE/$MODE.exit"
/system/bin/busybox chmod 644 "$BASE/$MODE.log" "$BASE/$MODE.exit"

View file

@ -0,0 +1,116 @@
# SPDX-License-Identifier: MPL-2.0
"""GSM SMS transport for the Nokia RIL (3GPP TS 23.040/23.038).
Default alphabet and extension characters match Gecko's GsmPDUHelper.
No binary messages, SIM commands or national-language shift tables are accepted.
"""
import datetime
import re
GSM='@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1bÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà'
EXT={10:'\f',20:'^',40:'{',41:'}',47:'\\',60:'[',61:'~',62:']',64:'|',101:'€'}
def number(value):
if not isinstance(value,str) or not re.fullmatch(r'\+?[0-9]{3,20}',value):raise ValueError('INVALID_NUMBER')
return value
def septets(text):
values=[]
for char in text:
if char in GSM and char!='\x1b':values.append(GSM.index(char))
elif char in EXT.values():values.extend((27,next(k for k,v in EXT.items() if v==char)))
else:return None
return values
def pack7(values):
packed=bytearray((len(values)*7+7)//8)
for i,value in enumerate(values):
bit=i*7;packed[bit//8]|=(value<<(bit%8))&255
if bit%8>1:packed[bit//8+1]|=value>>(8-bit%8)
return bytes(packed)
def unpack7(data,count,start=0):
if (start+count)*7>len(data)*8:raise ValueError('TRUNCATED_SMS')
result=[];escape=False
for i in range(start,start+count):
bit=i*7;value=(int.from_bytes(data[bit//8:bit//8+2],'little')>>(bit%8))&127
if escape:result.append(EXT.get(value,'<EFBFBD>'));escape=False
elif value==27:escape=True
else:result.append(GSM[value])
if escape:raise ValueError('TRUNCATED_SMS_ESCAPE')
return ''.join(result)
def submit(destination,text):
number(destination)
if not isinstance(text,str) or not text or '\x00' in text:raise ValueError('INVALID_SMS')
values=septets(text)
if values is not None:
if len(values)>160:raise ValueError('SMS_TOO_LONG')
payload=pack7(values);dcs=0;length=len(values)
else:
payload=text.encode('utf-16-be');dcs=8;length=len(payload)
if length>140:raise ValueError('SMS_TOO_LONG')
digits=destination.lstrip('+');padded=digits+('F' if len(digits)%2 else '')
address=bytes(int(padded[i+1]+padded[i],16) for i in range(0,len(padded),2))
# SMS-SUBMIT, no status-report request; SMSC is supplied separately by RIL.
return (bytes((1,0,len(digits),145 if destination.startswith('+') else 129))+
address+bytes((0,dcs,length))+payload).hex().upper()
def deliver(hexadecimal):
if not isinstance(hexadecimal,str) or len(hexadecimal)>1024:raise ValueError('INVALID_SMS_PDU')
try:data=bytes.fromhex(hexadecimal)
except ValueError:raise ValueError('INVALID_SMS_PDU') from None
pos=0
def take(n):
nonlocal pos
if n<0 or pos+n>len(data):raise ValueError('TRUNCATED_SMS')
part=data[pos:pos+n];pos+=n;return part
smsc=take(1)[0];take(smsc)
flags=take(1)[0]
if flags&3:raise ValueError('UNSUPPORTED_SMS_TYPE')
digits,toa=take(2);raw=take((digits+1)//2)
if toa&0x70==0x50:sender=unpack7(raw,digits*4//7)
else:
sender=''.join(format(b&15,'x')+format(b>>4,'x') for b in raw)[:digits]
if not sender.isdigit():raise ValueError('INVALID_SMS_SENDER')
if toa&0x70==0x10:sender='+'+sender
pid,dcs=take(2)
# Reject SIM data download / class-2 / compressed payloads rather than consuming them.
if pid or dcs not in (0,8):raise ValueError('UNSUPPORTED_SMS_ENCODING')
stamp=take(7)
def decimal(b):
lo,hi=b&15,b>>4
if lo>9 or hi>9:raise ValueError('INVALID_SMS_DATE')
return lo*10+hi
year,month,day,hour,minute,second=map(decimal,stamp[:6])
quarters=decimal(stamp[6]&0xf7)*(-1 if stamp[6]&8 else 1)
if abs(quarters)>56:raise ValueError('INVALID_SMS_DATE')
date=datetime.datetime(2000+year,month,day,hour,minute,second,
tzinfo=datetime.timezone(datetime.timedelta(minutes=quarters*15)))
length=take(1)[0];payload=take((length*7+7)//8 if dcs==0 else length)
header=0;concat=None
if flags&64:
if not payload:raise ValueError('TRUNCATED_SMS_HEADER')
header=payload[0]+1
if header>len(payload):raise ValueError('TRUNCATED_SMS_HEADER')
cursor=1
while cursor<header:
if cursor+2>header:raise ValueError('TRUNCATED_SMS_HEADER')
kind,size=payload[cursor:cursor+2];cursor+=2
part=payload[cursor:cursor+size];cursor+=size
if cursor>header:raise ValueError('TRUNCATED_SMS_HEADER')
if kind==0 and size==3:concat=(part[0],part[1],part[2])
elif kind==8 and size==4:concat=(part[0]*256+part[1],part[2],part[3])
else:raise ValueError('UNSUPPORTED_SMS_HEADER')
if concat and not 1<=concat[2]<=concat[1]<=10:raise ValueError('INVALID_SMS_PART')
if dcs==0:
skip=(header*8+6)//7
body=unpack7(payload,length-skip,skip)
else:body=payload[header:].decode('utf-16-be')
return {'sender':sender,'body':body,'timestamp':round(date.timestamp()*1000),'concat':concat}

View file

@ -0,0 +1,71 @@
# SPDX-License-Identifier: MPL-2.0
"""Stream volume for the Nokia Android 6 audio policy service.
AOSP android-6.0.1_r1 IAudioPolicyService: setStreamVolumeIndex=16,
getStreamVolumeIndex=17 (index, status), getDevicesForStream=24.
The firmware reports 0..5 for voice, 0..15 for the other streams below.
Only the currently routed outputs are changed; trial shutdown restores them.
"""
import math
import re
import subprocess
STREAMS={'telephony':(0,5),'notification':(5,15),'content':(3,15),'alarm':(4,15),'bt_sco':(6,15)}
class StreamVolume:
def __init__(self):
self.original={}
def transact(self,method,*values):
command=['/system/bin/service','call','media.audio_policy',str(method)]
for value in values:command.extend(('i32',str(value)))
result=subprocess.run(command,stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,
text=True,timeout=2,env={'PATH':'/system/bin','LD_LIBRARY_PATH':'/system/lib'})
if result.returncode:raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
# service prints either one line or address-prefixed Parcel rows.
text=result.stdout
if not text.startswith('Result: Parcel('):raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
text=re.sub(r"'[^']*'",'',text)
text=re.sub(r'0x[0-9a-fA-F]+:', '',text)
return [int(word,16) for word in re.findall(r'\b[0-9a-fA-F]{8}\b',text)]
def get(self,stream,device,maximum):
reply=self.transact(17,stream,device)
if len(reply)!=2 or reply[1]!=0 or not 0<=reply[0]<=maximum:
raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
return reply[0]
def state(self):
result={}
for name,(stream,maximum) in STREAMS.items():
reply=self.transact(24,stream)
if len(reply)!=1 or reply[0]&0x80000000:raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
mask=reply[0] or 0x40000000
device=mask&-mask
result[name]=self.get(stream,device,maximum)/maximum
return result
def request(self,value):
if not isinstance(value,dict) or value.get('stream') not in STREAMS:
raise ValueError('INVALID_REQUEST')
level=value.get('level')
if type(level) not in (int,float) or not math.isfinite(level) or not 0<=level<=1:
raise ValueError('INVALID_REQUEST')
stream,maximum=STREAMS[value['stream']]
reply=self.transact(24,stream)
if len(reply)!=1 or reply[0]&0x80000000:raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
mask=reply[0] or 0x40000000
devices=[1<<bit for bit in range(31) if mask&(1<<bit)]
index=int(level*maximum+.5)
for device in devices:
old=self.get(stream,device,maximum)
self.original.setdefault((stream,device),old)
if self.transact(16,stream,index,device)!=[0] or self.get(stream,device,maximum)!=index:
raise RuntimeError('CALL_AUDIO_UNAVAILABLE')
return {'stream':value['stream'],'index':index,'maximum':maximum,'devices':devices}
def restore(self):
for (stream,device),index in self.original.items():
try:self.transact(16,stream,index,device)
except (OSError,RuntimeError,subprocess.SubprocessError):pass

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,334 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Nokia Wi-Fi adapter: vendor supplicant, private client socket, no credentials in argv."""
import hashlib
import ipaddress
import json
import os
from pathlib import Path
import re
import signal
import socket
import struct
import subprocess
import sys
import time
import uuid
SOCKET = '/work/wifi-control.sock'
STATE = Path('/run/tundra-wifi/state.json')
WPA = '/work/radio-sockets/wpa_wlan0'
ERRORS = {'WIFI_UNAVAILABLE', 'WIFI_DISABLED', 'WIFI_INVALID_REQUEST', 'WIFI_INVALID_PASSWORD',
'WIFI_UNSUPPORTED_SECURITY', 'WIFI_CONNECTION_FAILED', 'WIFI_DHCP_FAILED', 'WIFI_SAVE_FAILED', 'NOT_SUPPORTED'}
def decode_ssid(text):
raw = bytearray()
for part in re.findall(r'\\x[0-9a-fA-F]{2}|\\.|[^\\]+|\\$', text):
if re.fullmatch(r'\\x[0-9a-fA-F]{2}', part): raw.append(int(part[2:], 16))
elif part.startswith('\\'): raw.extend({'\\n':b'\n','\\r':b'\r','\\t':b'\t','\\\\':b'\\','\\"':b'"'}.get(part,part.encode()))
else: raw.extend(part.encode())
return raw.decode('utf-8', errors='replace')
def security(flags):
if 'EAP' in flags or 'WEP' in flags: return 'unsupported'
if 'PSK' in flags: return 'wpa-psk'
if 'SAE' in flags: return 'unsupported'
return 'open'
def parse_scan(text):
rows=[]
for line in text.splitlines()[1:]:
parts=line.split('\t',4)
if len(parts)!=5 or not re.fullmatch(r'(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}',parts[0]): continue
try: signal_level=max(0,min(100,2*(int(parts[2])+100)))
except ValueError: continue
ssid=decode_ssid(parts[4])
if ssid: rows.append({'id':parts[0].lower(),'ssid':ssid,'signal':signal_level,'security':security(parts[3])})
return sorted(rows,key=lambda row:-row['signal'])[:128]
def android(*args, check=True):
result=subprocess.run(['/system/bin/'+args[0],*args[1:]],capture_output=True,text=True,timeout=5,
env={'PATH':'/system/bin','LD_LIBRARY_PATH':'/system/lib'})
if check and result.returncode: raise RuntimeError('WIFI_UNAVAILABLE')
return result.stdout.strip()
def command(value, required=True):
with socket.socket(socket.AF_UNIX,socket.SOCK_DGRAM) as sock:
# An abstract return address is reachable by the host supplicant outside our chroot.
sock.bind('\0vulpes-wifi-'+uuid.uuid4().hex)
sock.settimeout(3);sock.connect(WPA)
sock.send(('IFNAME=wlan0 '+value).encode())
result=sock.recv(65536).decode('utf-8',errors='replace').strip()
if required and (result.startswith('FAIL') or result=='UNKNOWN COMMAND'):
raise RuntimeError('WIFI_UNAVAILABLE')
return result
class Wifi:
def __init__(self):
self.last_scan=[];self.original_enabled=None;self.route=None;self.last_lease=0
self.initial_resolver=Path('/etc/resolv.conf').read_bytes() if Path('/etc/resolv.conf').exists() else None
self.rule_owned=False;self.stopping=False
self.lease_network=None
def enabled(self):
try: return bool(int(Path('/sys/class/net/wlan0/flags').read_text(),16)&1)
except OSError: return False
def state(self):
enabled=self.enabled()
result={'available':True,'enabled':enabled,'state':'disconnected','network':None,'connectionInformation':{}}
if not enabled: result['state']='disabled';return result
status=dict(line.split('=',1) for line in command('STATUS').splitlines() if '=' in line)
result['macAddress']=status.get('address','')
if status.get('wpa_state')=='COMPLETED':
result['state']='connected'
result['network']={'ssid':decode_ssid(status.get('ssid','')),'savedId':status.get('id'),
'security':security(status.get('key_mgmt','')),'signal':50}
try: result['connectionInformation']['ipAddress']=str(ipaddress.IPv4Address(status.get('ip_address','')))
except ValueError: pass
poll=command('SIGNAL_POLL',False)
for line in poll.splitlines():
try:
name,value=line.split('=',1);value=int(value)
if name=='RSSI': result['network']['signal']=max(0,min(100,2*(value+100)))
if name=='LINKSPEED': result['connectionInformation']['linkSpeed']=value
except ValueError: pass
return result
def known(self):
rows=[]
for line in command('LIST_NETWORKS').splitlines()[1:]:
parts=line.split('\t',3)
if len(parts)!=4 or not parts[0].isdigit(): continue
flags=command('GET_NETWORK '+parts[0]+' key_mgmt',False)
rows.append({'id':parts[0],'ssid':decode_ssid(parts[1]),'security':security(flags)})
return rows
def scan(self):
state=self.state()
if not state['enabled']:return {**state,'networks':[]}
# Subscribe before requesting a scan so fast completions are not lost.
with socket.socket(socket.AF_UNIX,socket.SOCK_DGRAM) as events:
events.bind('\0vulpes-wifi-scan-'+uuid.uuid4().hex);events.settimeout(3);events.connect(WPA)
events.send(b'IFNAME=wlan0 ATTACH');events.recv(4096)
command('SCAN',False)
deadline=time.monotonic()+4
while time.monotonic()<deadline:
try:
if b'CTRL-EVENT-SCAN-RESULTS' in events.recv(8192):break
except socket.timeout:break
rows=parse_scan(command('SCAN_RESULTS'));known=self.known();state=self.state()
for row in rows:
saved=next((n for n in known if n['ssid']==row['ssid'] and n['security']==row['security']),None)
row.update(known=bool(saved),savedId=saved['id'] if saved else None,
connected=bool(state['network'] and state['network']['ssid']==row['ssid']))
self.last_scan=rows
return {**state,'networks':rows}
def set_enabled(self, enabled):
if type(enabled) is not bool:raise ValueError('WIFI_INVALID_REQUEST')
if self.original_enabled is None:self.original_enabled=self.enabled()
self.route=None;self.lease_network=None;self.last_lease=0
if enabled:
android('ip','link','set','wlan0','up')
if not Path(WPA).exists():
android('setprop','ctl.start','wpa_supplicant')
for _ in range(30):
if Path(WPA).exists():break
time.sleep(.1)
command('RECONNECT')
else:
command('DISCONNECT',False)
android('ip','link','set','wlan0','down')
return self.state()
def connect(self,value):
if not self.enabled():raise RuntimeError('WIFI_DISABLED')
if value['operation']=='connectHidden':
ssid=value.get('ssid');mode=value.get('security')
else:
target=next((n for n in self.last_scan if n['id']==value.get('id')),None)
if target is None:raise ValueError('WIFI_INVALID_REQUEST')
ssid,mode=target['ssid'],target['security']
if not isinstance(ssid,str) or not 1<=len(ssid.encode())<=32:raise ValueError('WIFI_INVALID_REQUEST')
if mode not in ('open','wpa-psk'):raise ValueError('WIFI_UNSUPPORTED_SECURITY')
password=value.get('password','')
if not isinstance(password,str):raise ValueError('WIFI_INVALID_PASSWORD')
saved=next((n for n in self.known() if n['ssid']==ssid and n['security']==mode),None)
created=False
if saved and not password: ident=saved['id']
else:
if mode=='wpa-psk':
if re.fullmatch('[a-fA-F0-9]{64}',password):psk=password.lower()
elif 8<=len(password.encode())<=63:psk=hashlib.pbkdf2_hmac('sha1',password.encode(),ssid.encode(),4096,32).hex()
else:raise ValueError('WIFI_INVALID_PASSWORD')
ident=command('ADD_NETWORK')
if not ident.isdigit():raise RuntimeError('WIFI_UNAVAILABLE')
created=True
try:
if created:
command('SET_NETWORK '+ident+' ssid '+ssid.encode().hex())
command('SET_NETWORK '+ident+' key_mgmt '+('WPA-PSK' if mode=='wpa-psk' else 'NONE'))
if mode=='wpa-psk':command('SET_NETWORK '+ident+' psk '+psk)
if value['operation']=='connectHidden':command('SET_NETWORK '+ident+' scan_ssid 1')
command('SELECT_NETWORK '+ident)
deadline=time.monotonic()+20
while time.monotonic()<deadline:
if self.stopping:raise RuntimeError('WIFI_UNAVAILABLE')
state=self.state()
if state['state']=='connected' and state['network']['ssid']==ssid:
self.lease_network=None;self.last_lease=0
self.lease(state)
while time.monotonic()<deadline and not self.stopping:
if android('getprop','dhcp.wlan0.result')=='ok':break
time.sleep(.25)
else:raise RuntimeError('WIFI_DHCP_FAILED')
self.last_lease=0;self.lease(state)
if created and command('SAVE_CONFIG',False)!='OK':raise RuntimeError('WIFI_SAVE_FAILED')
return self.state()
time.sleep(.25)
raise RuntimeError('WIFI_CONNECTION_FAILED')
except Exception:
if created:command('REMOVE_NETWORK '+ident,False)
command('ENABLE_NETWORK all',False);command('RECONNECT',False)
raise
def lease(self,state):
if time.monotonic()-self.last_lease<3:return
self.last_lease=time.monotonic()
if state['state']!='connected':
self.lease_network=None;self.route=None
return
network_id=(state['network']['ssid'],state['network'].get('savedId'))
if network_id!=self.lease_network:
self.lease_network=network_id;self.route=None
# init.qcom.rc supplies -ABKLG but no interface. ctl.restart drops
# the dynamic argument; ctl.start must explicitly append wlan0.
android('setprop','ctl.stop','dhcpcd_wlan0')
for _ in range(20):
if android('getprop','init.svc.dhcpcd_wlan0')=='stopped':break
time.sleep(.1)
android('setprop','dhcp.wlan0.result','')
android('setprop','ctl.start','dhcpcd_wlan0:wlan0')
return
if android('getprop','dhcp.wlan0.result')!='ok':return
servers=[]
for name in ('dns1','dns2'):
try: servers.append(str(ipaddress.ip_address(android('getprop','dhcp.wlan0.'+name))))
except ValueError: pass
if servers:
content=''.join('nameserver '+ip+'\n' for ip in dict.fromkeys(servers))+'options timeout:2 attempts:2\n'
path=Path('/etc/resolv.conf')
if not path.exists() or path.read_text()!=content:
temporary=path.with_suffix('.tmp');temporary.write_text(content);temporary.chmod(0o644);temporary.replace(path)
# glibc does not use Android netd's per-socket routing marks. Install a
# dedicated table only for unmarked browser uid 2000; retain vendor tables.
try:
addr=ipaddress.IPv4Address(android('getprop','dhcp.wlan0.ipaddress'))
gateway=ipaddress.IPv4Address(android('getprop','dhcp.wlan0.gateway'))
mask=android('getprop','dhcp.wlan0.mask')
network=ipaddress.IPv4Network(str(addr)+'/'+mask,strict=False)
if gateway not in network:return
except ValueError:return
route=(str(network),str(gateway))
if route==self.route and android('ip','route','show','table','21052'):return
if not self.rule_owned:
if android('ip','route','show','table','21052') or '21900:' in android('ip','rule'):return
android('ip','rule','add','priority','21900','fwmark','0x0/0xffff','uidrange','2000-2000','lookup','21052')
self.rule_owned=True
android('ip','route','flush','table','21052')
android('ip','route','add',route[0],'dev','wlan0','table','21052')
android('ip','route','add','default','via',route[1],'dev','wlan0','table','21052')
self.route=route
def publish(self):
try:
state=self.state();self.lease(state)
except (OSError,RuntimeError,subprocess.SubprocessError):
state={'available':False,'enabled':False,'state':'unavailable','network':None}
STATE.parent.mkdir(parents=True,exist_ok=True)
temporary=STATE.with_suffix('.tmp');temporary.write_text(json.dumps(state));temporary.chmod(0o644);temporary.replace(STATE)
def request(self,value):
if not isinstance(value,dict):raise ValueError('WIFI_INVALID_REQUEST')
op=value.get('operation')
if op=='status':return self.state()
if op=='scan':return self.scan()
if op=='known':return {'networks':self.known()}
if op=='setEnabled':return self.set_enabled(value.get('enabled'))
if op in ('connect','connectHidden'):return self.connect(value)
if op=='disconnect':command('DISCONNECT');return self.state()
if op=='forget':
ident=value.get('id')
if not isinstance(ident,str) or not ident.isdigit() or len(ident)>6:raise ValueError('WIFI_INVALID_REQUEST')
command('REMOVE_NETWORK '+ident)
if command('SAVE_CONFIG',False)!='OK':raise RuntimeError('WIFI_SAVE_FAILED')
return True
raise ValueError('NOT_SUPPORTED')
def restore(self):
if self.original_enabled is not None:
try:self.set_enabled(self.original_enabled)
except Exception:pass
if self.rule_owned:
android('ip','rule','del','priority','21900','fwmark','0x0/0xffff','uidrange','2000-2000','lookup','21052',check=False)
android('ip','route','flush','table','21052',check=False)
resolver=Path('/etc/resolv.conf')
if self.initial_resolver is None:resolver.unlink(missing_ok=True)
else:resolver.write_bytes(self.initial_resolver)
def receive(conn, limit=8192):
data=b''
while b'\n' not in data:
part=conn.recv(4096)
if not part:break
data+=part
if len(data)>limit:raise ValueError('WIFI_INVALID_REQUEST')
return json.loads(data)
def main():
if sys.argv[1:]==['--client']:
raw=sys.stdin.buffer.read(8193)
if len(raw)>8192:raise ValueError('WIFI_INVALID_REQUEST')
value=json.loads(raw)
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as conn:
conn.settimeout(40);conn.connect(SOCKET);conn.sendall(json.dumps(value).encode()+b'\n')
print(json.dumps(receive(conn,65536)))
return
if sys.argv[1:] or os.getuid()!=0:raise SystemExit('Root broker or --client required')
os.umask(0o022);wifi=Wifi();running=True
def stop(*_):
nonlocal running
running=False;wifi.stopping=True
signal.signal(signal.SIGTERM,stop)
Path(SOCKET).unlink(missing_ok=True)
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as server:
server.bind(SOCKET);os.chown(SOCKET,0,2000);os.chmod(SOCKET,0o660);server.listen(8);server.settimeout(1)
print('Nokia Wi-Fi broker ready',flush=True)
try:
while running:
wifi.publish()
try:conn,_=server.accept()
except socket.timeout:continue
with conn:
conn.settimeout(2)
_,uid,_=struct.unpack('3i',conn.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12))
if uid not in (0,2000):continue
try:reply={'result':wifi.request(receive(conn))}
except (ValueError,RuntimeError,OSError,subprocess.SubprocessError) as error:
reply={'error':str(error) if str(error) in ERRORS else 'WIFI_UNAVAILABLE'}
try:conn.sendall(json.dumps(reply).encode()+b'\n')
except OSError:pass
finally:
wifi.restore();Path(SOCKET).unlink(missing_ok=True)
if __name__=='__main__':main()

View file

@ -0,0 +1,40 @@
Section "ServerFlags"
Option "AutoAddDevices" "false"
Option "AutoEnableDevices" "false"
Option "DontVTSwitch" "true"
Option "BlankTime" "0"
Option "StandbyTime" "0"
Option "SuspendTime" "0"
Option "OffTime" "0"
EndSection
Section "Module"
Disable "glx"
EndSection
Section "Device"
Identifier "NokiaFramebuffer"
Driver "fbdev"
Option "fbdev" "/dev/fb0"
Option "ShadowFB" "true"
EndSection
Section "Monitor"
Identifier "InternalDisplay"
EndSection
Section "Screen"
Identifier "InternalScreen"
Device "NokiaFramebuffer"
Monitor "InternalDisplay"
DefaultDepth 16
SubSectionSub "Display"
Depth 16
Virtual 240 320
EndSubSection
EndSection
Section "ServerLayout"
Identifier "Nokia"
Screen "InternalScreen"
EndSection

View file

@ -341,6 +341,18 @@ export async function run(win) {
}
}
async function requestedUiChecks() {
const regressionMarker=`${root}/logs/test-ticket-regressions`;
if(await IOUtils.exists(regressionMarker)) {
await IOUtils.remove(regressionMarker);
const {auditTickets}=ChromeUtils.importESModule(`resource://vulpes/host/TicketRegressionAudit.sys.mjs?run=${Date.now()}`);
await auditTickets({win,evaluate,wait,tap,sleep,root});
}
const browserMarker=`${root}/logs/test-browser-visual`;
if(await IOUtils.exists(browserMarker)) {
await IOUtils.remove(browserMarker);
const {auditBrowserVisual}=ChromeUtils.importESModule(`resource://vulpes/host/BrowserVisualAudit.sys.mjs?run=${Date.now()}`);
await auditBrowserVisual({win,evaluate,sleep,root});
}
const appearanceMarker = `${root}/logs/test-appearance`;
if (await IOUtils.exists(appearanceMarker)) {
await IOUtils.remove(appearanceMarker);
@ -382,6 +394,14 @@ export async function run(win) {
}
await IOUtils.writeJSON(`${root}/logs/appearance.json`,result);
}
const mediaMarker=`${root}/logs/test-media-feedback`;
if(await IOUtils.exists(mediaMarker)) {
await IOUtils.remove(mediaMarker);
const {auditMediaFeedback}=ChromeUtils.importESModule('resource://vulpes/host/MediaFeedbackAudit.sys.mjs');
const save=data=>IOUtils.writeJSON(`${root}/logs/media-feedback.json`,{bootId:report.bootId,...data});
try {await auditMediaFeedback({win,evaluate,wait,tap,sleep,save});}
catch(error){await save({passed:false,error:String(error)});}
}
const followupMarker=`${root}/logs/test-settings-followup`;
if(await IOUtils.exists(followupMarker)) {
await IOUtils.remove(followupMarker);

View file

@ -70,6 +70,8 @@ def main():
MOZ_ENABLE_WAYLAND='1', EGL_PLATFORM='wayland', GDK_GL='gles',
VULPES_FULLSCREEN='1', VULPES_NATIVE_TOUCH='1',
VULPES_WORKSPACE_ROOT=str(ROOT))
if env.get('VULPES_TUNDRA') == '1':
env['VULPES_SOCKET_CONTROL'] = '1'
if args.hybris_egl:
import hashlib
graphics = ROOT/'trial/graphics'
@ -174,8 +176,8 @@ Services.obs.addObserver({observe(win) {
raise RuntimeError('Gaia server startup timeout')
with (logs/'gecko.log').open('w') as output:
debug_args=['--remote-allow-system-access','-marionette','-new-window','about:blank'] if env.get('TUNDRA_LOCAL_DEBUGGER')=='1' else []
firefox = subprocess.Popen([str(runtime/'firefox'), *debug_args, '-purgecaches', '-no-remote',
'-profile', str(profile), '-chrome', 'chrome://vulpes/content/shell.xhtml'],
firefox = subprocess.Popen([str(runtime/'firefox'), '-purgecaches', '-no-remote',
'-profile', str(profile), '-chrome', 'chrome://vulpes/content/shell.xhtml', *debug_args],
env=env, stdout=output, stderr=subprocess.STDOUT, start_new_session=True)
processes.append(firefox)
result = firefox.wait()

View file

@ -0,0 +1,71 @@
/* SPDX-License-Identifier: MPL-2.0 */
/* Temporary ARM EABI runtime probe. Private mount namespace; Gecko runs as shell. */
#ifndef ROOT
#define ROOT "/data/local/tmp/vulpes-gecko-armhf/rootfs"
#endif
#ifndef GECKO
#define GECKO "/usr/lib/firefox-esr/firefox-esr"
#endif
static long call(long n, long a, long b, long c, long d, long e) {
register long r0 __asm__("r0") = a;
register long r1 __asm__("r1") = b;
register long r2 __asm__("r2") = c;
register long r3 __asm__("r3") = d;
register long r4 __asm__("r4") = e;
register long r7 __asm__("r7") = n;
__asm__ volatile("svc 0" : "+r"(r0) : "r"(r1), "r"(r2), "r"(r3), "r"(r4), "r"(r7) : "memory", "cc");
return r0;
}
static void output(const char *s) {
long len=0; while(s[len])len++;
call(4,2,(long)s,len,0,0);
}
static void check(long result, const char *operation) {
if(result>=0)return;
output(operation);output(" failed, errno=");
char buf[16];long i=15;buf[i]=0;result=-result;
do {buf[--i]='0'+result%10;result/=10;}while(result);
output(buf+i);output("\n");call(1,1,0,0,0,0);for(;;){}
}
static void device(const char *path, long minor) {
check(call(14,(long)path,8192|0666,256|minor,0,0),"mknod");
check(call(15,(long)path,0666,0,0,0),"device permissions");
}
__attribute__((noreturn)) void run(long *stack) {
long argc=stack[0]; char **argv=(char **)(stack+1);
int render=argc==2 && argv[1][0]=='r';
if(argc!=2 || (!render && argv[1][0]!='v')) {
output("Use version or render\n");call(1,2,0,0,0,0);for(;;){}
}
long fd=call(5,(long)"/proc/self/oom_score_adj",1,0,0,0);
check(fd,"oom_score_adj open");check(call(4,fd,(long)"1000",4,0,0),"oom_score_adj write");call(6,fd,0,0,0,0);
check(call(337,0x20000|0x40000000,0,0,0,0),"unshare mount and network namespaces");
check(call(21,0,(long)"/",0,16384|262144,0),"private mount propagation");
check(call(21,(long)"/proc",(long)ROOT "/proc",0,4096,0),"bind proc");
/* /data is nodev: character devices need a private tmpfs mount. */
check(call(21,(long)"tmpfs",(long)ROOT "/dev",(long)"tmpfs",2,(long)"size=1m,mode=0755"),"private devices");
device(ROOT "/dev/null",3);device(ROOT "/dev/zero",5);
device(ROOT "/dev/random",8);device(ROOT "/dev/urandom",9);
check(call(39,(long)ROOT "/dev/shm",01777,0,0,0),"shared memory directory");
check(call(21,(long)"tmpfs",(long)ROOT "/dev/shm",(long)"tmpfs",2|4,(long)"size=64m,mode=1777"),"private shared memory");
check(call(61,(long)ROOT,0,0,0,0),"chroot");
check(call(12,(long)"/",0,0,0,0),"chdir");
check(call(206,0,0,0,0,0),"drop supplementary groups");
check(call(214,2000,0,0,0,0),"setgid");
check(call(213,2000,0,0,0,0),"setuid");
check(call(172,38,1,0,0,0),"no new privileges");
char *env[]={"HOME=/work", "TMPDIR=/tmp", "MOZ_HEADLESS=1",
"MOZ_HEADLESS_WIDTH=240", "MOZ_HEADLESS_HEIGHT=320", "LANG=C",
"FONTCONFIG_PATH=/etc/fonts", "MOZ_CRASHREPORTER_DISABLE=1",
"LD_LIBRARY_PATH=/opt/firefox:/usr/lib/firefox-esr:/lib/arm-linux-gnueabihf:/usr/lib/arm-linux-gnueabihf",0};
char *versionArgs[]={GECKO,"--version",0};
char *renderArgs[]={GECKO,"--headless","--no-remote",
"--profile","/work/profile","--window-size","240,320",
"--screenshot","/work/gecko.png","file:///work/test.html",0};
output("VULPES ARMv7 Gecko probe: isolated mounts/network, uid=2000\n");
check(call(11,(long)versionArgs[0],(long)(render?renderArgs:versionArgs),(long)env,0,0),"exec Gecko");
call(1,1,0,0,0,0);for(;;){}
}
__attribute__((naked,noreturn)) void _start(void) {
__asm__ volatile("mov r0, sp\nb run");
}

View file

@ -0,0 +1,48 @@
/* SPDX-License-Identifier: MPL-2.0 */
/* Linux EABI smoke test: no libc, filesystem writes, or privileged operations. */
static long syscall3(long number, long a, long b, long c) {
register long r0 __asm__("r0") = a;
register long r1 __asm__("r1") = b;
register long r2 __asm__("r2") = c;
register long r7 __asm__("r7") = number;
__asm__ volatile("svc 0" : "+r"(r0) : "r"(r1), "r"(r2), "r"(r7) : "memory", "cc");
return r0;
}
static void print(const char *text) {
long length = 0;
while (text[length]) ++length;
while (length > 0) {
long count = syscall3(4, 1, (long)text, length);
if (count <= 0) return;
text += count;
length -= count;
}
}
static void number(unsigned long value) {
char buffer[16];
int end = 15;
buffer[end] = 0;
do {
buffer[--end] = '0' + value % 10;
value /= 10;
} while (value);
print(buffer + end);
}
__attribute__((noreturn)) void _start(void) {
struct { char system[65], node[65], release[65], version[65], machine[65], domain[65]; } uts;
long status = syscall3(122, (long)&uts, 0, 0);
print("VULPES_ARMV7_PROBE=1\n");
print("This is a native diagnostic, not Gecko or a replacement OS.\nuid=");
number((unsigned long)syscall3(24, 0, 0, 0));
print("\n");
if (status == 0) {
print("kernel="); print(uts.release);
print("\narchitecture="); print(uts.machine); print("\n");
}
print(status == 0 ? "result=PASS\n" : "result=FAIL\n");
syscall3(1, status == 0 ? 0 : 1, 0, 0);
for (;;) {}
}

View file

@ -0,0 +1,63 @@
/* SPDX-License-Identifier: MPL-2.0 */
/* Unprivileged one-request relay. The root broker still validates every command. */
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#define REQUEST_LIMIT 8192
#define RESPONSE_LIMIT 65536
static int write_all(int fd, const char *data, size_t size) {
while (size) {
ssize_t count=write(fd,data,size);
if (count<0 && errno==EINTR) continue;
if (count<=0) return -1;
data+=count;size-=(size_t)count;
}
return 0;
}
int main(int argc,char **argv) {
const char *path;
if (argc!=2) return 2;
if (!strcmp(argv[1],"--radio")) path="/work/phone-control.sock";
else if (!strcmp(argv[1],"--wifi")) path="/work/wifi-control.sock";
else return 2;
signal(SIGPIPE,SIG_IGN);
/* Bound the whole exchange, including a truncated stdin or an unresponsive broker. */
alarm(40);
char request[REQUEST_LIMIT+1];size_t used=0;
for (;;) {
ssize_t count=read(STDIN_FILENO,request+used,sizeof(request)-used);
if (count<0 && errno==EINTR) continue;
if (count<0) return 1;
if (!count) break;
used+=(size_t)count;
if (used>REQUEST_LIMIT) return 2;
}
if (!used || memchr(request,'\n',used) || memchr(request,'\r',used)) return 2;
int fd=socket(AF_UNIX,SOCK_STREAM,0);
if (fd<0) return 1;
struct sockaddr_un address={.sun_family=AF_UNIX};
memcpy(address.sun_path,path,strlen(path)+1);
if (connect(fd,(struct sockaddr *)&address,sizeof(address)) ||
write_all(fd,request,used) || write_all(fd,"\n",1)) {close(fd);return 1;}
char response[RESPONSE_LIMIT+1];used=0;
for (;;) {
ssize_t count=read(fd,response+used,sizeof(response)-used);
if (count<0 && errno==EINTR) continue;
if (count<=0) {close(fd);return 1;}
used+=(size_t)count;
if (used>RESPONSE_LIMIT) {close(fd);return 1;}
char *end=memchr(response,'\n',used);
if (end) {
int result=write_all(STDOUT_FILENO,response,(size_t)(end-response)+1);
close(fd);return result?1:0;
}
}
}

View file

@ -0,0 +1,463 @@
/* SPDX-License-Identifier: MPL-2.0 */
#define _GNU_SOURCE
#include <dirent.h>
#include <errno.h>
#include <fcntl.h>
#include <grp.h>
#include <linux/fb.h>
#include <linux/videodev2.h>
#include <net/if.h>
#include <sched.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/file.h>
#include <sys/ioctl.h>
#include <sys/mount.h>
#include <sys/mman.h>
#include <sys/prctl.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/sysmacros.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>
#define BASE "/data/local/vulpes-beatles"
#define ROOT BASE "/rootfs"
#include "beatles-lid.h"
static volatile sig_atomic_t interrupted;
static int phone_controls;
static void interrupt(int sig) { interrupted = sig; }
static void inventory_camera_nodes(void) {
static const int minors[] = {0, 1, 32, 33};
for (unsigned i = 0; i < sizeof(minors)/sizeof(minors[0]); i++) {
char path[32];
snprintf(path, sizeof(path), "/dev/video%d", minors[i]);
int fd = open(path, O_RDWR | O_NONBLOCK | O_CLOEXEC);
if (fd < 0) { printf("Camera probe %s: open failed (%d)\n", path, errno); continue; }
struct v4l2_capability caps = {0};
if (ioctl(fd, VIDIOC_QUERYCAP, &caps)) {
printf("Camera probe %s: VIDIOC_QUERYCAP failed (%d)\n", path, errno);
close(fd); continue;
}
uint32_t device_caps = (caps.capabilities & V4L2_CAP_DEVICE_CAPS) ? caps.device_caps : caps.capabilities;
printf("Camera probe %s: driver=%s card=%s caps=%08x\n", path, caps.driver, caps.card, device_caps);
for (unsigned type_index = 0; type_index < 2; type_index++) {
uint32_t type = type_index ? V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE : V4L2_BUF_TYPE_VIDEO_CAPTURE;
if (!(device_caps & (type_index ? V4L2_CAP_VIDEO_CAPTURE_MPLANE : V4L2_CAP_VIDEO_CAPTURE))) continue;
for (uint32_t index = 0; index < 16; index++) {
struct v4l2_fmtdesc format = {.index = index, .type = type};
if (ioctl(fd, VIDIOC_ENUM_FMT, &format)) break;
printf("Camera probe %s: capture format=%c%c%c%c flags=%08x\n", path,
format.pixelformat & 0xff, (format.pixelformat >> 8) & 0xff,
(format.pixelformat >> 16) & 0xff, (format.pixelformat >> 24) & 0xff, format.flags);
}
}
close(fd);
}
}
static int copy_camera_dump(const char *directory, const char *extension,
const char *destination, const char *label) {
DIR *camera_dir = opendir(directory);
if (!camera_dir) return 0;
struct dirent *entry;
size_t extension_length = strlen(extension);
int copied = 0;
while ((entry = readdir(camera_dir))) {
size_t name_length = strlen(entry->d_name);
if (name_length <= extension_length ||
strcmp(entry->d_name + name_length - extension_length, extension)) continue;
char source[256];
snprintf(source, sizeof(source), "%s/%s", directory, entry->d_name);
int in = open(source, O_RDONLY | O_CLOEXEC);
int out = open(destination, O_CREAT | O_TRUNC | O_WRONLY | O_CLOEXEC, 0644);
if (in >= 0 && out >= 0) {
char buffer[65536]; ssize_t bytes;
while ((bytes = read(in, buffer, sizeof(buffer))) > 0) {
ssize_t offset = 0;
while (offset < bytes) {
ssize_t written = write(out, buffer + offset, (size_t)(bytes - offset));
if (written <= 0) break;
offset += written;
}
if (offset != bytes) break;
}
struct stat copied_stat;
if (!fstat(out, &copied_stat) && copied_stat.st_size > 0) {
printf("QCamera captured %s %s (%ld bytes)\n", label, entry->d_name,
(long)copied_stat.st_size);
copied = 1;
}
fchmod(out, 0644); close(out);
} else {
if (out >= 0) close(out);
if (in >= 0) close(in);
}
if (in >= 0) close(in);
if (copied) break;
}
closedir(camera_dir);
return copied;
}
static void require(int result, const char *what) {
if (result < 0) { perror(what); _exit(1); }
}
static void node(const char *path, int major, int minor, mode_t mode) {
require(mknod(path, S_IFCHR | mode, makedev(major, minor)), path);
require(chmod(path, mode), "chmod device");
}
static int android_service(const char *operation) {
pid_t child = fork();
if (!child) { execl(operation, operation, "b2g", NULL); _exit(127); }
int status;
return child > 0 && waitpid(child, &status, 0) == child && WIFEXITED(status)
? WEXITSTATUS(status) : -1;
}
/* nsIProcess may pass open descriptors from B2G. Keeping a RIL socket alive
* after stopping B2G prevents the vendor daemon from accepting our reader. */
static int close_inherited(int *sockets) {
DIR *dir = opendir("/proc/self/fd");
if (!dir) return -1;
int count = 0;
struct dirent *entry;
while ((entry = readdir(dir))) {
char *end, path[64], target[128];
long fd = strtol(entry->d_name, &end, 10);
if (*end || fd < 3 || fd == dirfd(dir)) continue;
snprintf(path,sizeof(path),"/proc/self/fd/%ld",fd);
ssize_t n = readlink(path,target,sizeof(target)-1);
if (n > 0) {target[n] = 0; if (!strncmp(target,"socket:[",8)) (*sockets)++;}
if (!close((int)fd)) count++;
}
closedir(dir);
return count;
}
static void clean_runtime(void) {
DIR *dir = opendir("/proc");
if (!dir) return;
struct dirent *entry;
while ((entry = readdir(dir))) {
char *end, path[128], target[512];
long pid = strtol(entry->d_name, &end, 10);
if (*end || pid <= 1 || pid == getpid()) continue;
snprintf(path, sizeof(path), "/proc/%ld/root", pid);
ssize_t n = readlink(path, target, sizeof(target) - 1);
if (n > 0) {
target[n] = 0;
if (!strcmp(target, ROOT)) kill((pid_t)pid, SIGKILL);
}
}
closedir(dir);
}
static void capture_framebuffer(int fd) {
struct fb_var_screeninfo v;
struct fb_fix_screeninfo f;
if (ioctl(fd,FBIOGET_VSCREENINFO,&v) || ioctl(fd,FBIOGET_FSCREENINFO,&f)) return;
if (v.xres != 240 || v.yres != 320 || v.bits_per_pixel != 16) return;
size_t length=(size_t)(v.yoffset+v.yres)*f.line_length;
if (length>f.smem_len || (v.xoffset+v.xres)*2>f.line_length) return;
unsigned char *pixels=mmap(NULL,length,PROT_READ,MAP_SHARED,fd,0);
if (pixels==MAP_FAILED) { perror("framebuffer capture mmap");return; }
FILE *image=fopen(BASE "/framebuffer.ppm","wb");
if (image) {
fchmod(fileno(image),0644);fprintf(image,"P6\n%u %u\n255\n",v.xres,v.yres);
unsigned char row[240*3];
for (unsigned y=0;y<v.yres;y++) {
for (unsigned x=0;x<v.xres;x++) {
uint16_t pixel;
memcpy(&pixel,pixels+(y+v.yoffset)*f.line_length+(x+v.xoffset)*2,2);
const struct fb_bitfield channels[]={v.red,v.green,v.blue};
for (unsigned c=0;c<3;c++) {
unsigned mask=(1u<<channels[c].length)-1;
row[x*3+c]=mask?((pixel>>channels[c].offset)&mask)*255/mask:0;
}
}
fwrite(row,1,sizeof(row),image);
}
fclose(image);puts("Framebuffer captured after Gaia screenshot");
}
munmap(pixels,length);
}
/* Hardware brokers own the vendor sockets; Gecko never receives their mounts. */
static void radio_inside(void) {
require(setsid(), "radio setsid");
require(unshare(CLONE_NEWNS), "radio mount namespace");
require(mount(NULL,"/",NULL,MS_REC | MS_PRIVATE,NULL),"radio private mounts");
require(mount("tmpfs",ROOT "/dev","tmpfs",MS_NOSUID,"size=1m,mode=0755"),"radio devices");
node(ROOT "/dev/null",1,3,0666);node(ROOT "/dev/urandom",1,9,0666);
require(mount("/sys",ROOT "/sys",NULL,MS_BIND,NULL),"radio sys");
require(mount(NULL,ROOT "/sys",NULL,MS_BIND | MS_REMOUNT | MS_RDONLY,NULL),"radio readonly sys");
if (mkdir(ROOT "/work/radio-sockets",0700) && errno != EEXIST) _exit(1);
require(mount("/dev/socket",ROOT "/work/radio-sockets",NULL,MS_BIND,NULL),"radio socket mount");
if(phone_controls) {
/* Keep /sys read-only. Expose only these controls to the root broker,
* never to the browser's separate mount namespace. */
if(mkdir(ROOT "/work/hardware-controls",0700) && errno!=EEXIST)_exit(1);
require(chmod(ROOT "/work/hardware-controls",0700),"hardware permissions");
const char *sources[]={"/sys/class/leds/lcd-backlight/brightness",
"/sys/class/leds/lcd-backlight/max_brightness","/sys/class/timed_output/vibrator/enable"};
const char *targets[]={ROOT "/work/hardware-controls/backlight",
ROOT "/work/hardware-controls/backlight-max",ROOT "/work/hardware-controls/vibrator"};
for(unsigned i=0;i<3;i++) {
int control=open(targets[i],O_CREAT|O_WRONLY|O_CLOEXEC,0600);
require(control,"hardware control file");close(control);
require(mount(sources[i],targets[i],NULL,MS_BIND,NULL),"hardware control mount");
}
if(mkdir(ROOT "/dev/socket",0700) && errno!=EEXIST)_exit(1);
const char *property_sources[]={"/dev/__properties__","/dev/socket/property_service"};
const char *property_targets[]={ROOT "/dev/__properties__",ROOT "/dev/socket/property_service"};
for(unsigned i=0;i<2;i++) {
int target=open(property_targets[i],O_CREAT|O_WRONLY|O_CLOEXEC,0600);
require(target,"property endpoint");close(target);
require(mount(property_sources[i],property_targets[i],NULL,MS_BIND,NULL),"property endpoint mount");
}
struct stat binder;
require(stat("/dev/binder",&binder),"binder device");
node(ROOT "/dev/binder",major(binder.st_rdev),minor(binder.st_rdev),0600);
if(mkdir(ROOT "/system",0755) && errno!=EEXIST)_exit(1);
require(mount("/system",ROOT "/system",NULL,MS_BIND,NULL),"radio system");
require(mount(NULL,ROOT "/system",NULL,MS_BIND|MS_REMOUNT|MS_RDONLY,NULL),"radio readonly system");
}
require(chroot(ROOT),"radio chroot");require(chdir("/"),"radio chdir");
clearenv();setenv("PATH","/usr/bin:/bin",1);setenv("HOME","/work",1);
setenv("VULPES_RIL_SOCKET_DIR","/work/radio-sockets",1);
setenv("LD_LIBRARY_PATH","/usr/lib/arm-linux-gnueabihf:/lib/arm-linux-gnueabihf",1);
int log=open("/work/radio-status.log",O_CREAT | O_TRUNC | O_WRONLY,0644);
require(log,"radio log");require(fchmod(log,0644),"radio log mode");dup2(log,1);dup2(log,2);close(log);
execl("/usr/bin/python3.11","python3.11",phone_controls?"/work/radio_control.py":"/work/radio_status.py",NULL);
_exit(127);
}
static void inside(int seconds) {
require(setsid(), "worker setsid");
require(unshare(CLONE_NEWNS | (phone_controls ? 0 : CLONE_NEWNET)), "unshare");
require(mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL), "private mounts");
require(mount("/proc", ROOT "/proc", NULL, MS_BIND, NULL), "bind proc");
require(mount("/sys", ROOT "/sys", NULL, MS_BIND, NULL), "bind sys");
require(mount(NULL, ROOT "/sys", NULL, MS_BIND | MS_REMOUNT | MS_RDONLY, NULL), "readonly sys");
require(mount("tmpfs", ROOT "/dev", "tmpfs", MS_NOSUID, "size=1m,mode=0755"), "devices");
node(ROOT "/dev/null",1,3,0666); node(ROOT "/dev/zero",1,5,0666);
node(ROOT "/dev/random",1,8,0666); node(ROOT "/dev/urandom",1,9,0666);
node(ROOT "/dev/tty",5,0,0600); node(ROOT "/dev/tty0",4,0,0600);
node(ROOT "/dev/tty1",4,1,0600); node(ROOT "/dev/fb0",29,0,0600);
node(ROOT "/dev/fb1",29,1,0600);
require(mkdir(ROOT "/dev/input",0755), "input directory");
node(ROOT "/dev/input/event0",13,64,0600);
node(ROOT "/dev/input/event2",13,66,0600);
node(ROOT "/dev/input/event3",13,67,0600);
require(mkdir(ROOT "/dev/shm",01777), "shm directory");
require(mount("tmpfs", ROOT "/dev/shm", "tmpfs", MS_NOSUID | MS_NODEV,
"size=64m,mode=1777"), "shared memory");
int sock = socket(AF_INET,SOCK_DGRAM,0);
require(sock,"loopback socket");
struct ifreq request = {0}; strcpy(request.ifr_name,"lo");
request.ifr_flags = IFF_UP | IFF_RUNNING;
require(ioctl(sock,SIOCSIFFLAGS,&request),"loopback up"); close(sock);
require(chroot(ROOT),"chroot"); require(chdir("/"),"chdir");
clearenv(); setenv("PATH","/usr/bin:/bin",1); setenv("HOME","/work",1);
setenv("VULPES_TRIAL_MANUAL",seconds >= 900 ? "1" : "0",1);
setenv("VULPES_TUNDRA_RADIO",phone_controls?"1":"0",1);
setenv("VULPES_TUNDRA_WIFI",phone_controls?"1":"0",1);
setenv("LANG","C",1); setenv("DISPLAY",":0",1);
setenv("LD_LIBRARY_PATH","/usr/lib/arm-linux-gnueabihf:/lib/arm-linux-gnueabihf",1);
execl("/bin/sh","sh","/work/display-test.sh",NULL);
perror("session script"); _exit(1);
}
int main(int argc, char **argv) {
if (getuid() || argc != 2) return 2;
phone_controls=!strcmp(argv[1],"--phone-1800s") || !strcmp(argv[1],"--phone-900s") || !strcmp(argv[1],"--phone-180s");
int seconds = !strcmp(argv[1],"--temporary-45s") ? 45 :
!strcmp(argv[1],"--temporary-90s") ? 90 :
(!strcmp(argv[1],"--temporary-180s") || !strcmp(argv[1],"--phone-180s")) ? 180 :
(!strcmp(argv[1],"--interactive-900s") || !strcmp(argv[1],"--phone-900s")) ? 900 :
(!strcmp(argv[1],"--interactive-1800s") || !strcmp(argv[1],"--phone-1800s")) ? 1800 : 0;
if (!seconds) return 2;
int inherited_sockets = 0;
int inherited = close_inherited(&inherited_sockets);
if (inherited < 0) return 6;
int lock = open(BASE "/session.lock",O_CREAT | O_RDWR,0600);
if (lock < 0 || flock(lock,LOCK_EX | LOCK_NB)) return 3;
int fb = open("/dev/graphics/fb0",O_RDWR | O_CLOEXEC);
struct fb_var_screeninfo original;
if (fb < 0 || ioctl(fb,FBIOGET_VSCREENINFO,&original)) return 4;
pid_t detached = fork();
if (detached < 0) return 5;
if (detached) return 0;
require(setsid(),"watchdog setsid");
const char *groups[] = {"/acct/cgroup.procs", "/dev/cpuctl/tasks"};
char pidtext[32]; int length=snprintf(pidtext,sizeof(pidtext),"%d",getpid());
for(unsigned i=0;i<sizeof(groups)/sizeof(groups[0]);i++) {
int group=open(groups[i],O_WRONLY);
require(group,"watchdog cgroup");
require(write(group,pidtext,length),"detach watchdog cgroup"); close(group);
}
signal(SIGTERM,interrupt); signal(SIGINT,interrupt); signal(SIGHUP,SIG_IGN);
int log = open(BASE "/display-session.log",O_CREAT | O_TRUNC | O_WRONLY,0644);
require(log,"log"); fchmod(log,0644); dup2(log,1); dup2(log,2); close(log);
setvbuf(stdout,NULL,_IONBF,0); setvbuf(stderr,NULL,_IONBF,0);
printf("Closed inherited descriptors: %d, sockets: %d\n",inherited,inherited_sockets);
inventory_camera_nodes();
printf("Temporary display session: automatic KaiOS return in %d seconds\n",seconds);
unlink(BASE "/stop-session");
unlink(BASE "/framebuffer.ppm");
unlink(ROOT "/work/gaia-native.png");
unlink(ROOT "/work/gaia-audit.json");
/* Camera output is also stored in the RAM-backed Gaia root. Never let a
* previous run's image be mistaken for a frame from this session. */
unlink(ROOT "/work/camera-frame.yuv");
unlink(ROOT "/work/camera-photo.jpg");
sleep(2);
if (!access(BASE "/camera-probe-once", F_OK)) {
unlink(BASE "/camera-probe-once");
pid_t probe = fork();
if (!probe) { execl("/data/local/tmp/vulpes-camera-stream", "vulpes-camera-stream", NULL); _exit(127); }
if (probe > 0) {
int probe_status = 0, finished = 0;
for (int attempt = 0; attempt < 100; attempt++) {
if (waitpid(probe, &probe_status, WNOHANG) == probe) { finished = 1; break; }
usleep(100000);
}
if (!finished) { kill(probe, SIGKILL); waitpid(probe, &probe_status, 0); puts("camera probe timed out after 10 seconds"); }
else printf("camera probe exit: %d\n", WIFEXITED(probe_status) ? WEXITSTATUS(probe_status) : -1);
}
}
int result = android_service("/system/bin/stop");
printf("stop b2g: %d\n",result);
pid_t child = -1, radio = -1;
struct lid_state lid;lid_init(&lid,fb);
if (!result) {
sleep(1);
puts("Camera node inventory after stopping B2G");
inventory_camera_nodes();
int preview_dump_probe = !access(BASE "/qcamera-preview-dump-once", F_OK);
if (!access(BASE "/qcamera-probe-once", F_OK) || preview_dump_probe) {
unlink(BASE "/qcamera-probe-once");
unlink(BASE "/qcamera-preview-dump-once");
const char *dump_path = "/data/misc/camera";
if (mkdir("/data/misc", 0755) && errno != EEXIST) perror("camera misc directory");
if (mkdir(dump_path, 0770) && errno != EEXIST) perror("camera dump directory");
char snapshot_dir[256];
snprintf(snapshot_dir, sizeof(snapshot_dir), "%s/test", dump_path);
if (mount("tmpfs", dump_path, "tmpfs", MS_NOSUID, "size=24m,mode=0770,uid=1006,gid=1006")) perror("camera dump tmpfs");
else {
puts("QCamera snapshot output uses bounded volatile storage");
if (mkdir(snapshot_dir, 0770) && errno != EEXIST) perror("camera snapshot directory");
if (chown(snapshot_dir, 1006, 1006)) perror("camera snapshot ownership");
}
pid_t prop = fork();
if (!prop) { execl("/system/bin/setprop", "setprop", "persist.camera.dumpimg", "1", NULL); _exit(127); }
if (prop > 0) waitpid(prop, NULL, 0);
int input[2];
if (pipe(input) == 0) {
pid_t probe = fork();
if (!probe) {
close(input[1]);
dup2(input[0], STDIN_FILENO);
close(input[0]);
int output = open(BASE "/qcamera-probe.log", O_CREAT | O_TRUNC | O_WRONLY, 0644);
if (output >= 0) { fchmod(output, 0644); dup2(output, STDOUT_FILENO); dup2(output, STDERR_FILENO); close(output); }
gid_t groups[] = {1006};
if (setgroups(1, groups) || setgid(1006) || setuid(1006)) _exit(126);
const char *qcamera = preview_dump_probe ? BASE "/qcamera-preview-dump" : "/system/bin/mm-qcamera-app";
execl(qcamera, qcamera, NULL);
_exit(127);
}
close(input[0]);
if (probe > 0) {
const char *camera_input = preview_dump_probe ? "0\nA\n" : "0\na\nA\n";
(void)write(input[1], camera_input, strlen(camera_input));
/* Give the sensor's auto-exposure and white balance time to settle. */
sleep(10);
(void)write(input[1], "P\n", 2);
sleep(12);
(void)write(input[1], "B\nZ\n", 4);
close(input[1]);
int probe_status = 0, finished = 0;
for (int attempt = 0; attempt < 250; attempt++) {
if (waitpid(probe, &probe_status, WNOHANG) == probe) { finished = 1; break; }
usleep(100000);
}
if (!finished) { kill(probe, SIGKILL); waitpid(probe, &probe_status, 0); puts("QCamera menu probe timed out"); }
else printf("QCamera menu exit: %d\n", WIFEXITED(probe_status) ? WEXITSTATUS(probe_status) : -1);
} else close(input[1]);
} else perror("QCamera probe pipe");
int frame_copied = copy_camera_dump(dump_path, ".yuv", ROOT "/work/camera-frame.yuv", "YUV frame");
if (!frame_copied) frame_copied = copy_camera_dump(snapshot_dir, ".yuv", ROOT "/work/camera-frame.yuv", "snapshot YUV");
int jpeg_copied = copy_camera_dump(snapshot_dir, ".jpg", ROOT "/work/camera-photo.jpg", "JPEG photo");
if (!frame_copied && !jpeg_copied) puts("QCamera acquired preview buffers but did not export a frame or photo");
prop = fork();
if (!prop) { execl("/system/bin/setprop", "setprop", "persist.camera.dumpimg", "0", NULL); _exit(127); }
if (prop > 0) waitpid(prop, NULL, 0);
if (!umount(dump_path)) rmdir(dump_path);
}
if (seconds >= 180) {
radio = fork();
if (!radio) radio_inside();
}
child = fork();
if (!child) inside(seconds);
struct timespec begin, now;
clock_gettime(CLOCK_MONOTONIC,&begin);
int frames=0,captured=0;
double sample_start=begin.tv_sec+begin.tv_nsec/1e9,pan_seconds=0;
int sample_frames=0,pan_attempts=0;
for (int tick=0; child > 0 && !interrupted; tick++) {
clock_gettime(CLOCK_MONOTONIC,&now);
if (now.tv_sec-begin.tv_sec >= seconds) break;
int status;
if (waitpid(child,&status,WNOHANG)==child) {
printf("session ended: %d\n",status); break;
}
if (!access(BASE "/stop-session",F_OK)) break;
if (!captured && !access(ROOT "/work/gaia-native.png",F_OK)) {
capture_framebuffer(fb);captured=1;
}
if(seconds>=180)lid_update(&lid,now.tv_sec+now.tv_nsec/1e9);
struct fb_var_screeninfo current;
if (lid.opened != 0 && !ioctl(fb,FBIOGET_VSCREENINFO,&current)) {
struct timespec pan_start,pan_end;
clock_gettime(CLOCK_MONOTONIC,&pan_start);
int pan=ioctl(fb,FBIOPAN_DISPLAY,&current);
clock_gettime(CLOCK_MONOTONIC,&pan_end);
pan_seconds+=pan_end.tv_sec-pan_start.tv_sec+(pan_end.tv_nsec-pan_start.tv_nsec)/1e9;
pan_attempts++;
if (pan && tick==20) perror("display refresh");
if (!pan) {frames++;sample_frames++;}
}
double sample_now=now.tv_sec+now.tv_nsec/1e9;
if(sample_now-sample_start>=10) {
printf("Display sample: submissions/s=%.2f pan_ms=%.2f failed=%d lid=%d\n",
sample_frames/(sample_now-sample_start),pan_attempts?1000*pan_seconds/pan_attempts:0,
pan_attempts-sample_frames,lid.opened);
sample_start=sample_now;pan_seconds=0;sample_frames=0;pan_attempts=0;
}
usleep(lid.opened==0?20000:5000);
}
clock_gettime(CLOCK_MONOTONIC,&now);
printf("Framebuffer submissions: %d in %.2f seconds\n",frames,
now.tv_sec-begin.tv_sec+(now.tv_nsec-begin.tv_nsec)/1e9);
}
if (child > 0) { kill(-child,SIGTERM); sleep(1); kill(-child,SIGKILL); }
if (radio > 0) {
kill(-radio,SIGTERM);
// Allow both modem slots to restore before returning to KaiOS.
for(int i=0;i<600;i++) {
if(waitpid(radio,NULL,WNOHANG)==radio) { radio=-1;break; }
usleep(100000);
}
if(radio>0) {kill(-radio,SIGKILL);waitpid(radio,NULL,0);}
}
clean_runtime();
lid_restore(&lid);
if (ioctl(fb,FBIOPUT_VSCREENINFO,&original)) perror("restore framebuffer mode");
if (ioctl(fb,FBIOPAN_DISPLAY,&original)) perror("restore framebuffer pan");
close(fb);
unlink("/data/local/debugger-socket");
printf("start b2g: %d\n",android_service("/system/bin/start"));
int done=open(BASE "/display-session.done",O_CREAT | O_TRUNC | O_WRONLY,0644);
if(done>=0){fchmod(done,0644);write(done,"restored\n",9);close(done);}
return 0;
}

View file

@ -0,0 +1,43 @@
/* SPDX-License-Identifier: MPL-2.0 */
/* Read-only Android 6 ARM32 HAL enumeration in an isolated bionic process.
* ABI: AOSP android-6.0.1_r1 hardware/libhardware/include/hardware/
* hardware.h, sensors.h. No sensor activation or camera device open. */
typedef unsigned int u32;
typedef unsigned short u16;
extern void *dlopen(const char *,int);
extern void *dlsym(void *,const char *);
extern int printf(const char *,...);
extern int putchar(int);
extern int fflush(void *);
extern unsigned alarm(unsigned);
extern void _exit(int) __attribute__((noreturn));
struct module {u32 tag;u16 version,hal;const char *id,*name,*author;void *methods,*dso;u32 reserved[25];};
struct sensor {const char *name,*vendor;int version,handle,type;float range,resolution,power;int min_delay;u32 fifo_reserved,fifo_max;const char *string_type,*permission;int max_delay;u32 flags;void *reserved[2];};
struct sensors {struct module common;int (*list)(struct sensors *,const struct sensor **);};
_Static_assert(sizeof(struct module)==128,"Android 6 ARM32 module ABI");
_Static_assert(sizeof(struct sensor)==68,"Android 6 ARM32 sensor ABI");
static void string(const char *value) {
putchar('"');
if(value)for(unsigned i=0;value[i] && i<160;i++) {
unsigned char c=value[i];
if(c=='"'||c=='\\')putchar('\\');
if(c<32)printf("\\u%04x",c);else putchar(c);
}
putchar('"');
}
__attribute__((constructor)) static void inventory(void) {
alarm(8);
void *library=dlopen("/system/lib/hw/sensors.msm8909.so",2);
struct sensors *module=library?dlsym(library,"HMI"):0;
if(!module || !module->list){printf("{\"error\":\"SENSOR_HAL_UNAVAILABLE\"}\n");fflush(0);_exit(1);}
const struct sensor *list=0;
int count=module->list(module,&list);
if(count<0 || count>128 || (count && !list)){printf("{\"error\":\"INVALID_SENSOR_LIST\"}\n");fflush(0);_exit(2);}
printf("{\"moduleVersion\":%u,\"count\":%d,\"sensors\":[",module->common.version,count);
for(int i=0;i<count;i++) {
if(i)putchar(',');
printf("{\"type\":%d,\"name\":",list[i].type);string(list[i].name);
printf(",\"vendor\":");string(list[i].vendor);putchar('}');
}
printf("]}\n");fflush(0);_exit(0);
}

View file

@ -0,0 +1,9 @@
/* SPDX-License-Identifier: MPL-2.0 */
#ifndef VULPES_BEATLES_HALL_H
#define VULPES_BEATLES_HALL_H
/* Nokia hall_status reports the magnet: 1 = closed, 0 = open.
* Keep this conversion shared by panel power and keypad handling. */
static inline int beatles_hall_open(int raw) {
return raw == 0 ? 1 : raw == 1 ? 0 : -1;
}
#endif

View file

@ -0,0 +1,185 @@
/* SPDX-License-Identifier: MPL-2.0 */
#include <X11/Xlib.h>
#include <X11/keysym.h>
#include <X11/XF86keysym.h>
#include <X11/extensions/XTest.h>
#include <errno.h>
#include <fcntl.h>
#include <linux/input.h>
#include <poll.h>
#include <stdio.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include "beatles-hall.h"
static int allow_key(unsigned code, int value, int hall) {
return !value || beatles_hall_open(hall)!=0 ||
code==KEY_VOLUMEUP || code==KEY_VOLUMEDOWN;
}
static KeySym symbol(unsigned code) {
if(code>=KEY_1 && code<=KEY_9)return XK_1+code-KEY_1;
switch(code) {
case KEY_0:return XK_0;
case KEY_VOLUMEUP:return XF86XK_AudioRaiseVolume;
case KEY_VOLUMEDOWN:return XF86XK_AudioLowerVolume;
case KEY_UP:return XK_Up; case KEY_DOWN:return XK_Down;
case KEY_LEFT:return XK_Left; case KEY_RIGHT:return XK_Right;
case KEY_OK:case KEY_ENTER:return XK_Return;
case KEY_BACK:return XK_Escape; case KEY_BACKSPACE:return XK_BackSpace;
case KEY_MENU:return XK_Menu; case KEY_SEND:return XK_F1;
case KEY_POWER:return XK_F4; /* qpnp_pon.kl: ENDCALL */
case KEY_CAMERA:return XK_F2; /* matrix_keypad.kl: NOTIFICATION */
case KEY_NUMERIC_STAR:return XK_8;
case KEY_NUMERIC_POUND:return XK_3;
default:return NoSymbol;
}
}
/* Exercise the evdev reader as well as X11. These diagnostic commands are
* used only while our reader owns the keypad; they do not bypass its mapping. */
static unsigned native_code(const char *key) {
if(!strcmp(key,"ArrowRight"))return KEY_RIGHT;
if(!strcmp(key,"ArrowLeft"))return KEY_LEFT;
if(!strcmp(key,"ArrowUp"))return KEY_UP;
if(!strcmp(key,"ArrowDown"))return KEY_DOWN;
if(!strcmp(key,"Enter"))return KEY_OK;
if(!strcmp(key,"Escape"))return KEY_BACK;
if(!strcmp(key,"EndCall"))return KEY_POWER;
if(!strcmp(key,"Notifications"))return KEY_CAMERA;
if(!strcmp(key,"AudioVolumeUp"))return KEY_VOLUMEUP;
if(!strcmp(key,"AudioVolumeDown"))return KEY_VOLUMEDOWN;
if(!strcmp(key,"Backspace"))return KEY_BACKSPACE;
if(!key[0] || key[1])return 0;
if(key[0]>='1' && key[0]<='9')return KEY_1+key[0]-'1';
if(key[0]=='0')return KEY_0;
if(key[0]=='*')return KEY_NUMERIC_STAR;
if(key[0]=='#')return KEY_NUMERIC_POUND;
return 0;
}
static int inject(int fd,unsigned code) {
struct input_event events[4]={
{.type=EV_KEY,.code=code,.value=1}, {.type=EV_SYN,.code=SYN_REPORT},
{.type=EV_KEY,.code=code,.value=0}, {.type=EV_SYN,.code=SYN_REPORT},
};
ssize_t written=write(fd,events,sizeof(events));
if(written!=(ssize_t)sizeof(events)){perror("keypad inject");return 1;}
return 0;
}
static int inject_keys(const char *keys,int sequence) {
size_t length=strlen(keys);
if((sequence && (!length || length>40 || strspn(keys,"0123456789*#")!=length)) ||
(!sequence && !native_code(keys)))return 2;
unsigned code=sequence?0:native_code(keys);
const char *path=code==KEY_VOLUMEUP ? "/dev/input/event3" :
(code==KEY_POWER || code==KEY_VOLUMEDOWN) ? "/dev/input/event2" : "/dev/input/event0";
int fd=open(path,O_WRONLY|O_CLOEXEC);
if(fd<0){perror("keypad injection open");return 2;}
if(!ioctl(fd,EVIOCGRAB,1)) {
ioctl(fd,EVIOCGRAB,0);close(fd);
fputs("Keypad reader must own the device before injection\n",stderr);return 3;
}
if(errno!=EBUSY){perror("keypad ownership");close(fd);return 3;}
int result=0;
for(size_t i=0;i<(sequence?length:1);i++) {
char single[2]={keys[i],0};
if(inject(fd,native_code(sequence?single:keys))){result=1;break;}
usleep(130000);
}
close(fd);return result;
}
int main(int argc,char **argv) {
if(argc==3 && !strcmp(argv[1],"--inject-key"))return inject_keys(argv[2],0);
if(argc==3 && !strcmp(argv[1],"--inject-keys"))return inject_keys(argv[2],1);
Display *display=NULL;
for(unsigned attempt=0;attempt<100 && !display;attempt++) {
display=XOpenDisplay(NULL);
if(!display)usleep(100000);
}
if(!display){fputs("Keypad: X server not ready after 10 seconds\n",stderr);return 1;}
if(argc==3 && !strcmp(argv[1],"--type-keys")) {
size_t length=strlen(argv[2]);
if(!length || length>40 || strspn(argv[2],"0123456789*#")!=length)return 2;
for(size_t i=0;i<length;i++) {
char key=argv[2][i];int shifted=key=='*'||key=='#';
KeyCode code=XKeysymToKeycode(display,key=='*'?XK_8:key=='#'?XK_3:XK_0+key-'0');
if(shifted)XTestFakeKeyEvent(display,XKeysymToKeycode(display,XK_Shift_L),True,CurrentTime);
XTestFakeKeyEvent(display,code,True,CurrentTime);
XTestFakeKeyEvent(display,code,False,CurrentTime);
if(shifted)XTestFakeKeyEvent(display,XKeysymToKeycode(display,XK_Shift_L),False,CurrentTime);
XFlush(display);usleep(130000);
}
XSync(display,False);XCloseDisplay(display);return 0;
}
if(argc==3 && !strcmp(argv[1],"--send-key")) {
KeySym key=!strcmp(argv[2],"ArrowRight")?XK_Right:
!strcmp(argv[2],"ArrowLeft")?XK_Left:!strcmp(argv[2],"ArrowUp")?XK_Up:
!strcmp(argv[2],"ArrowDown")?XK_Down:!strcmp(argv[2],"Backspace")?XK_BackSpace:
!strcmp(argv[2],"Enter")?XK_Return:!strcmp(argv[2],"Escape")?XK_Escape:
!strcmp(argv[2],"2")?XK_2:NoSymbol;
if(key==NoSymbol){XCloseDisplay(display);return 2;}
KeyCode code=XKeysymToKeycode(display,key);
XTestFakeKeyEvent(display,code,True,CurrentTime);
XTestFakeKeyEvent(display,code,False,CurrentTime);
XSync(display,False);XCloseDisplay(display);return 0;
}
if(argc==2 && !strcmp(argv[1],"--self-test")) {
unsigned keys[]={KEY_UP,KEY_OK,KEY_BACK};
for(unsigned i=0;i<sizeof(keys)/sizeof(keys[0]);i++) {
KeyCode code=XKeysymToKeycode(display,symbol(keys[i]));
XTestFakeKeyEvent(display,code,True,CurrentTime);
XTestFakeKeyEvent(display,code,False,CurrentTime);
}
XSync(display,False);XCloseDisplay(display);return 0;
}
int fd=open("/dev/input/event0",O_RDONLY|O_NONBLOCK);
if(fd<0){perror("keypad open");return 2;}
if(ioctl(fd,EVIOCGRAB,1)){perror("keypad grab");return 3;}
int power=open("/dev/input/event2",O_RDONLY|O_NONBLOCK);
char name[80]={0};
if(power<0 || ioctl(power,EVIOCGNAME(sizeof(name)),name)<0 || strcmp(name,"qpnp_pon") || ioctl(power,EVIOCGRAB,1)) {
fprintf(stderr,"Nokia end-call input unavailable: %s (name=%s)\n",strerror(errno),name);
if(power>=0)close(power);
ioctl(fd,EVIOCGRAB,0);close(fd);XCloseDisplay(display);return 3;
}
int side=open("/dev/input/event3",O_RDONLY|O_NONBLOCK);
memset(name,0,sizeof(name));
if(side<0 || ioctl(side,EVIOCGNAME(sizeof(name)),name)<0 || strcmp(name,"gpio-keys") || ioctl(side,EVIOCGRAB,1)) {
fprintf(stderr,"Nokia volume input unavailable: %s (name=%s)\n",strerror(errno),name);
if(side>=0)close(side);
ioctl(power,EVIOCGRAB,0);close(power);ioctl(fd,EVIOCGRAB,0);close(fd);XCloseDisplay(display);return 3;
}
puts("Nokia keypad bridge ready");fflush(stdout);
struct pollfd pollers[3]={{.fd=fd,.events=POLLIN},{.fd=power,.events=POLLIN},{.fd=side,.events=POLLIN}};
for(;;) {
if(poll(pollers,3,1000)<0)break;
for(unsigned source=0;source<3;source++) {
if(!(pollers[source].revents&POLLIN))continue;
struct input_event event;
while(read(pollers[source].fd,&event,sizeof(event))==sizeof(event)) {
if(source==1 && event.code!=KEY_POWER && event.code!=KEY_VOLUMEDOWN)continue;
if(source==2 && event.code!=KEY_VOLUMEUP && event.code!=KEY_VOLUMEDOWN)continue;
/* Long holds must not hang up and then immediately dismiss the screen. */
if((event.code==KEY_POWER || event.code==KEY_VOLUMEUP || event.code==KEY_VOLUMEDOWN) && event.value==2)continue;
if(event.type!=EV_KEY)continue;
/* Always release keys, but ignore presses from the closed inner keypad. */
if(event.value) {
FILE *hall=fopen("/sys/devices/soc.0/hall_sensor1.73/hall_status","r");
int opened=-1;
if(hall){if(fscanf(hall,"%d",&opened)!=1)opened=-1;fclose(hall);}
if(!allow_key(event.code,event.value,opened))continue;
}
KeySym key=symbol(event.code);
if(key==NoSymbol)continue;
int shifted=event.code==KEY_NUMERIC_STAR||event.code==KEY_NUMERIC_POUND;
if(shifted && event.value)XTestFakeKeyEvent(display,XKeysymToKeycode(display,XK_Shift_L),True,CurrentTime);
XTestFakeKeyEvent(display,XKeysymToKeycode(display,key),event.value!=0,CurrentTime);
if(shifted && !event.value)XTestFakeKeyEvent(display,XKeysymToKeycode(display,XK_Shift_L),False,CurrentTime);
XFlush(display);
}
}
}
ioctl(side,EVIOCGRAB,0);close(side);
ioctl(power,EVIOCGRAB,0);close(power);
ioctl(fd,EVIOCGRAB,0);close(fd);XCloseDisplay(display);return 0;
}

View file

@ -0,0 +1,90 @@
/* SPDX-License-Identifier: MPL-2.0 */
/* Panel policy belongs to the Nokia adapter, outside the browser sandbox. */
#include "beatles-hall.h"
struct lid_state {
int inner, outer, opened, outer_on, ready, candidate;
int keys_brightness;
double last_poll, closed_at, candidate_at;
};
#ifndef BEATLES_KEYS_BRIGHTNESS
#define BEATLES_KEYS_BRIGHTNESS "/sys/class/leds/button-backlight/brightness"
#endif
static int lid_keys_read(void) {
FILE *f=fopen(BEATLES_KEYS_BRIGHTNESS,"r");
if(!f)return -1;
int value=-1;if(fscanf(f,"%d",&value)!=1)value=-1;
fclose(f);return value;
}
static void lid_keys_write(int value) {
if(value<0)return;
FILE *f=fopen(BEATLES_KEYS_BRIGHTNESS,"w");
if(!f){perror("keypad brightness");return;}
if(fprintf(f,"%d\n",value)<0)perror("keypad brightness write");
if(fclose(f))perror("keypad brightness close");
}
static int lid_read_open(void) {
FILE *f=fopen("/sys/devices/soc.0/hall_sensor1.73/hall_status","r");
if(!f)return -1;
int value=-1;
if(fscanf(f,"%d",&value)!=1)value=-1;
fclose(f);return beatles_hall_open(value);
}
static void lid_publish(const struct lid_state *lid) {
FILE *f=fopen(ROOT "/work/lid-state.tmp","w");
if(!f)return;
fchmod(fileno(f),0644);
fprintf(f,"{\"opened\":%s,\"externalOn\":%s}\n",lid->opened?"true":"false",lid->outer_on?"true":"false");
fclose(f);rename(ROOT "/work/lid-state.tmp",ROOT "/work/lid-state.json");
}
static void lid_init(struct lid_state *lid,int inner) {
memset(lid,0,sizeof(*lid));lid->inner=inner;lid->opened=-1;lid->candidate=-1;
lid->keys_brightness=lid_keys_read();
lid->outer=open("/dev/graphics/fb1",O_RDWR|O_CLOEXEC);
unlink(ROOT "/work/cover-ready");unlink(ROOT "/work/lid-state.json");
}
static void lid_apply(struct lid_state *lid,int opened,int ready,double now) {
if(opened!=0 && opened!=1)return;
/* Xorg can wake fb0 during initialization after our first closed-lid poll.
* cover-ready is published after Xorg/keypad startup: reapply the panel
* selection then, before presenting the first external frame. */
int changed=opened!=lid->opened || (ready && !lid->ready && !opened);
if(changed) {
/* Finish blanking the cover before waking the inner display. */
if(opened && lid->outer>=0) {
if(ioctl(lid->outer,FBIOBLANK,FB_BLANK_POWERDOWN)){perror("lid outer blank");return;}
lid->outer_on=0;
}
if(ioctl(lid->inner,FBIOBLANK,opened?FB_BLANK_UNBLANK:FB_BLANK_POWERDOWN)) {
perror("lid inner blank");return;
}
lid_keys_write(opened?lid->keys_brightness:0);
lid->opened=opened;lid->closed_at=opened?0:now;
printf("Clamshell: %s; internal screen %s\n",opened?"open":"closed",opened?"on":"off");
}
if(ready && !lid->ready && !opened)lid->closed_at=now;
lid->ready=ready;
int outer=lid->outer>=0 && !opened && now-lid->closed_at<15 && ready;
int outer_changed=outer!=lid->outer_on;
if(outer_changed || (changed && !opened)) {
if(lid->outer>=0 && ioctl(lid->outer,FBIOBLANK,outer?FB_BLANK_UNBLANK:FB_BLANK_POWERDOWN))perror("lid outer blank");
else lid->outer_on=outer;
printf("External screen: %s\n",lid->outer_on?"on":"off");
}
if(changed || outer_changed)lid_publish(lid);
}
static void lid_update(struct lid_state *lid,double now) {
if(now-lid->last_poll<.1)return;
lid->last_poll=now;
int opened=lid_read_open();
if(opened<0)return;
if(opened!=lid->candidate) {
lid->candidate=opened;lid->candidate_at=now;return;
}
if(now-lid->candidate_at<.15)return;
lid_apply(lid,opened,!access(ROOT "/work/cover-ready",F_OK),now);
}
static void lid_restore(struct lid_state *lid) {
if(lid->outer>=0) {ioctl(lid->outer,FBIOBLANK,FB_BLANK_POWERDOWN);close(lid->outer);}
if(lid->inner>=0 && ioctl(lid->inner,FBIOBLANK,FB_BLANK_UNBLANK))perror("restore internal panel");
lid_keys_write(lid->keys_brightness);
}

View file

@ -0,0 +1,25 @@
/* SPDX-License-Identifier: MPL-2.0 */
#include <fcntl.h>
#include <linux/fb.h>
#include <linux/input.h>
#include <stdio.h>
#include <sys/ioctl.h>
#include <sys/stat.h>
#include <unistd.h>
int main(void) {
FILE *out=fopen("/data/local/tmp/vulpes-panel-info.json","w");
if(!out)return 1;
fchmod(fileno(out),0644);
fprintf(out,"{\"panels\":[");
for(int i=0;i<2;i++) {
char path[64];snprintf(path,sizeof(path),"/dev/graphics/fb%d",i);
int fd=open(path,O_RDONLY);struct fb_var_screeninfo v;struct fb_fix_screeninfo f;
if(fd<0 || ioctl(fd,FBIOGET_VSCREENINFO,&v) || ioctl(fd,FBIOGET_FSCREENINFO,&f))return 2;
fprintf(out,"%s{\"width\":%u,\"height\":%u,\"virtualHeight\":%u,\"bpp\":%u,\"stride\":%u,\"bytes\":%u}",i?",":"",v.xres,v.yres,v.yres_virtual,v.bits_per_pixel,f.line_length,f.smem_len);
close(fd);
}
unsigned char keys[(KEY_MAX+8)/8]={0};int fd=open("/dev/input/event1",O_RDONLY);
if(fd<0 || ioctl(fd,EVIOCGKEY(sizeof(keys)),keys)<0)return 3;
fprintf(out,"],\"hallKey249\":%s}\n",(keys[249/8]&(1<<(249%8)))?"true":"false");
close(fd);fclose(out);return 0;
}

View file

@ -0,0 +1,55 @@
/* SPDX-License-Identifier: MPL-2.0 */
#include <X11/Xlib.h>
#include <X11/Xutil.h>
#include <stdio.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
int main(void) {
Display *display=XOpenDisplay(NULL);
if(!display){fputs("Cannot connect to Xorg\n",stderr);return 1;}
int screen=DefaultScreen(display);
Window window=XCreateSimpleWindow(display,RootWindow(display,screen),0,0,240,320,
0,0,0x0010);
XSetWindowAttributes attributes={.override_redirect=True};
XChangeWindowAttributes(display,window,CWOverrideRedirect,&attributes);
XSelectInput(display,window,ExposureMask|KeyPressMask|KeyReleaseMask);
XMapRaised(display,window); XSetInputFocus(display,window,RevertToParent,CurrentTime);
GC gc=XCreateGC(display,window,0,NULL);
char keytext[128]="Press a physical key";
time_t start=time(NULL);
int count=0;
while(time(NULL)-start<25) {
while(XPending(display)) {
XEvent event; XNextEvent(display,&event);
if(event.type==KeyPress||event.type==KeyRelease) {
KeySym key=XLookupKeysym(&event.xkey,0);
snprintf(keytext,sizeof(keytext),"%s: %s",event.type==KeyPress?"Down":"Up",
XKeysymToString(key)?XKeysymToString(key):"unknown");
printf("keycode=%u keysym=%lu %s\n",event.xkey.keycode,key,keytext);fflush(stdout);
}
}
XSetForeground(display,gc,0x0010);XFillRectangle(display,window,gc,0,0,240,320);
XSetForeground(display,gc,0xffff);
const char *lines[]={"VULPES DISPLAY TEST","Linux / Xorg / ARMv7","240 x 320 / physical keypad",keytext,"KaiOS returns automatically"};
for(unsigned i=0;i<sizeof(lines)/sizeof(lines[0]);i++)
XDrawString(display,window,gc,10,35+i*35,lines[i],strlen(lines[i]));
XSetForeground(display,gc,0x07ff); XFillRectangle(display,window,gc,10+(count++%160),235,50,25);
XFlush(display);usleep(100000);
}
XImage *image=XGetImage(display,window,0,0,240,320,AllPlanes,ZPixmap);
if(image){FILE *file=fopen("/work/x11-test.ppm","wb");if(file){
fprintf(file,"P6\n240 320\n255\n");
for(int y=0;y<320;y++) {
for(int x=0;x<240;x++) {
unsigned long pixel=XGetPixel(image,x,y);
unsigned char rgb[]={((pixel>>11)&31)*255/31,((pixel>>5)&63)*255/63,(pixel&31)*255/31};
fwrite(rgb,1,3,file);
}
}
fclose(file);
}
XDestroyImage(image);}
XCloseDisplay(display);return 0;
}

View file

@ -39,6 +39,9 @@ class Vibration:
raise ValueError('INVALID_VIBRATION')
self.steps = list(enumerate(pattern))
self.deadline = None
trigger = VIBRATOR/'trigger'
if trigger.exists():
trigger.write_text('none')
(VIBRATOR/'brightness').write_text('0')
self.advance(time.monotonic())
@ -60,11 +63,27 @@ class Vibration:
vibration = Vibration()
class WakeRecovery:
def __init__(self):
self.deadline = None
def arm(self, now):
self.deadline = now + 1.5
def advance(self, now):
if self.deadline is not None and now >= self.deadline:
self.deadline = None
if int((BACKLIGHT/'brightness').read_text()) == 0:
control({'operation':'screen', 'enabled':True, 'level':0.5})
wake_recovery = WakeRecovery()
def control(p):
if p.get('operation') == 'screen':
enabled, level = p.get('enabled'), p.get('level')
if type(enabled) is not bool or type(level) not in (int, float) or not 0 <= level <= 1:
raise ValueError('INVALID_SCREEN')
wake_recovery.deadline = None
maximum = int((BACKLIGHT/'max_brightness').read_text())
(BACKLIGHT/'brightness').write_text(str(max(1, round(maximum * level)) if enabled else 0))
elif p.get('operation') == 'vibrate':
@ -125,15 +144,16 @@ def serve():
_, _, kind, code, value = INPUT.unpack_from(data, offset)
event = key_event(kind, code, value)
if event:
# Always allow a physical press to recover a black display,
# even if Gaia has stopped responding.
# Let Gaia restore the lock screen before lighting it.
# Recover independently only if its screen command never arrives.
if code == 116 and value == 1 and int((BACKLIGHT/'brightness').read_text()) == 0:
control({'operation':'screen', 'enabled':True, 'level':0.5})
wake_recovery.arm(time.monotonic())
state['sequence'] += 1
state['events'].append({'id':state['sequence'], 'type':event})
state['events'] = state['events'][-64:]
if vibration.deadline is not None:
vibration.advance(time.monotonic())
wake_recovery.advance(time.monotonic())
publish()
def client():

View file

@ -127,6 +127,7 @@ class Camera:
def metadata(self):
return {'ready': True, 'width': self.width, 'height': self.height,
'orientation': self.orientation, 'format': 'nv21',
'flashModes': [m for m in self.parameters.get('flash-mode-values', 'off').split(',') if m in ('off','auto','on','torch')],
'pictureSizes': [{'width':w,'height':h} for w,h in self.picture_sizes],
'focusModes': self.parameters.get('focus-mode-values', 'fixed').split(','),
'maxFocusAreas': min(1,int(self.parameters.get('max-num-focus-areas', 0))),
@ -135,6 +136,16 @@ class Camera:
def control(self, command):
P, I = C.c_void_p, C.c_int
action = command.get('action')
if action == 'flash':
mode = command.get('mode')
modes = ('off', 'auto', 'on', 'torch')
if mode not in modes or mode not in self.metadata()['flashModes']:
raise ValueError('CAMERA_FLASH_UNAVAILABLE')
function(self.lib, 'android_camera_set_flash_mode', [P,I])(self.camera,modes.index(mode))
actual = self.read_parameters().get('flash-mode', 'off')
if actual != mode:
raise RuntimeError('CAMERA_FLASH_READBACK_FAILED')
return {'mode':actual}
if action == 'zoom':
ratio = command.get('zoom')
if not isinstance(ratio, (int,float)) or not self.zoom_ratios[0] <= ratio <= self.zoom_ratios[-1]:

View file

@ -0,0 +1,59 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Preserve wall time across boots when Qualcomm's RTC has no Unix epoch."""
import json
from pathlib import Path
import time
STATE = Path('/var/lib/tundra/clock/last-time.json')
RTC = Path('/sys/class/rtc/rtc0/since_epoch')
def rtc_seconds():
try:
return int(RTC.read_text().strip())
except (OSError, ValueError):
return None
def valid_epoch(value):
return type(value) in (int, float) and 946684800 <= value <= 4102444800
def restore():
try:
saved = json.loads(STATE.read_text())
epoch, previous = saved['epoch'], saved.get('rtc')
if not valid_epoch(epoch):
return False
current = rtc_seconds()
# A reset/replaced RTC is not evidence of time elapsed while powered off.
if type(previous) is int and current is not None and 0 <= current - previous <= 366*86400:
epoch += current - previous
if valid_epoch(epoch) and time.time() < epoch:
time.clock_settime(time.CLOCK_REALTIME, epoch)
return True
except (OSError, ValueError, KeyError, TypeError):
pass
return False
def save():
epoch = time.time()
if not valid_epoch(epoch):
return
STATE.parent.mkdir(parents=True, exist_ok=True)
temporary = STATE.with_suffix('.tmp')
temporary.write_text(json.dumps({'epoch': epoch, 'rtc': rtc_seconds()}) + '\n')
temporary.chmod(0o600)
temporary.replace(STATE)
if __name__ == '__main__':
import sys
if sys.argv[1:] == ['--restore']:
restore()
elif sys.argv[1:] == ['--save']:
save()
else:
raise SystemExit('Use --restore or --save')

View file

@ -8,6 +8,11 @@ from pathlib import Path
CLOCK_CONFIG = Path("/var/lib/tundra/clock/settings.json")
def checkpoint_clock():
import runpy
runpy.run_path(str(Path(__file__).with_name('clock-state.py')))['save']()
def clock_automatic():
try:
return json.loads(CLOCK_CONFIG.read_text())["automatic"] is True
@ -43,6 +48,7 @@ def sync_network_clock():
if abs(time.time() - seconds) > 2:
# oFono timestamps use CLOCK_MONOTONIC, not wall time or boot time.
time.clock_settime(time.CLOCK_REALTIME, seconds)
checkpoint_clock()
@ -159,6 +165,8 @@ def device_request(value):
result = subprocess.run(['/usr/bin/timedatectl', *args], capture_output=True, text=True, timeout=10)
if result.returncode:
raise RuntimeError('CLOCK_REQUEST_FAILED')
if action == 'setTime':
checkpoint_clock()
result = subprocess.run(['/usr/bin/timedatectl', 'show', '-p', 'Timezone', '-p', 'NTP', '-p', 'CanNTP'],
capture_output=True, text=True, timeout=5)
if result.returncode:

View file

@ -37,6 +37,38 @@ def prepare(root, network=None, hardware=None, diagnostics=False):
ExecStartPre=/usr/bin/fc-cache -f /usr/share/fonts/opentype/vulpes
''')
write('etc/os-release','NAME="Tundra"\nID=tundra\nPRETTY_NAME="Tundra Sargo development trial"\n')
(root/'etc/tundra').mkdir(parents=True, exist_ok=True)
shutil.copy2(Path(__file__).with_name('clock-state.py'), root/'etc/tundra/clock-state.py')
write('etc/systemd/system/tundra-clock-restore.service', """[Unit]
Description=Restore Tundra clock from persistent state and RTC elapsed time
DefaultDependencies=no
After=local-fs.target
Before=time-set.target tundra-display.service tundra-radio.service
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /etc/tundra/clock-state.py --restore
RemainAfterExit=yes
ExecStop=/usr/bin/python3 /etc/tundra/clock-state.py --save
StateDirectory=tundra/clock
""")
write('etc/systemd/system/tundra-clock-save.service', """[Unit]
Description=Checkpoint Tundra clock
After=tundra-clock-restore.service
[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /etc/tundra/clock-state.py --save
StateDirectory=tundra/clock
""")
write('etc/systemd/system/tundra-clock-save.timer', """[Unit]
Description=Checkpoint Tundra clock once per minute
[Timer]
OnBootSec=60
OnUnitActiveSec=60
""")
wants = root/'etc/systemd/system/tundra.target.wants'
wants.mkdir(parents=True, exist_ok=True)
for unit in ('tundra-clock-restore.service', 'tundra-clock-save.timer'):
(wants/unit).symlink_to('../'+unit)
write('etc/hostname','vulpes-sargo\n')
write('etc/shells','/bin/sh\n/bin/bash\n/usr/bin/bash\n')
write('etc/pam.d/tundra','auth required pam_permit.so\naccount required pam_permit.so\nsession required pam_systemd.so\n')
@ -177,6 +209,7 @@ NoNewPrivileges=yes
write('etc/NetworkManager/conf.d/80-vulpes-hotspot.conf',Path(__file__).with_name('hotspot-firewall.conf').read_text())
for name in ('iptables','ip6tables'):
alias=root/'usr/sbin'/name
alias.parent.mkdir(parents=True, exist_ok=True)
if not alias.exists() and not alias.is_symlink():alias.symlink_to(name+'-legacy')
write('etc/tundra/network-enabled', 'sargo\n')
display = root/'etc/systemd/system/tundra-display.service'

View file

@ -0,0 +1,26 @@
/* SPDX-License-Identifier: MPL-2.0 */
#include <assert.h>
#define main keypad_reader_main
#include "../probes/beatles-keypad.c"
#undef main
int main(void) {
for(unsigned code=KEY_1;code<=KEY_0;code++)assert(symbol(code)!=NoSymbol);
const unsigned controls[]={KEY_UP,KEY_DOWN,KEY_LEFT,KEY_RIGHT,KEY_OK,
KEY_BACK,KEY_BACKSPACE,KEY_MENU,KEY_SEND,KEY_POWER,KEY_CAMERA,
KEY_NUMERIC_STAR,KEY_NUMERIC_POUND,KEY_VOLUMEUP,KEY_VOLUMEDOWN};
for(unsigned i=0;i<sizeof(controls)/sizeof(controls[0]);i++)
assert(symbol(controls[i])!=NoSymbol);
assert(native_code("Notifications")==KEY_CAMERA);
assert(symbol(KEY_CAMERA)==XK_F2);
/* Do not give the assistance key an accidental call/emergency action. */
assert(symbol(112)==NoSymbol);
assert(!allow_key(KEY_SEND,1,1));
assert(!allow_key(KEY_2,1,1));
assert(allow_key(KEY_SEND,0,1));
assert(allow_key(KEY_VOLUMEUP,1,1));
assert(allow_key(KEY_VOLUMEDOWN,1,1));
assert(allow_key(KEY_2,1,0));
puts("Nokia key map and closed-lid input policy: OK");
return 0;
}

View file

@ -0,0 +1,56 @@
/* SPDX-License-Identifier: MPL-2.0 */
#include <assert.h>
#include <fcntl.h>
#include <linux/fb.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#define ROOT "/nonexistent-vulpes-lid-test"
static char keys_path[]="/tmp/vulpes-lid-keys-XXXXXX";
#define BEATLES_KEYS_BRIGHTNESS keys_path
static int last_panel=-1;
static int inner=-1,outer=-1,fail_inner=0;
static int panel_ioctl(int fd,unsigned long request,int value) {
assert(request==FBIOBLANK);
last_panel=fd;
if(fd==10){if(fail_inner)return -1;inner=value;}
else {assert(fd==11);outer=value;}
return 0;
}
#define ioctl panel_ioctl
#include "../probes/beatles-lid.h"
int main(void) {
assert(beatles_hall_open(0)==1);
assert(beatles_hall_open(1)==0);
assert(beatles_hall_open(-1)==-1);
assert(beatles_hall_open(2)==-1);
int fixture=mkstemp(keys_path);assert(fixture>=0);close(fixture);
lid_keys_write(40);assert(lid_keys_read()==40);
struct lid_state lid={.inner=10,.outer=11,.opened=-1,.keys_brightness=40};
lid_apply(&lid,1,1,100);assert(inner==FB_BLANK_UNBLANK && outer==FB_BLANK_POWERDOWN);
lid_apply(&lid,0,1,101);assert(inner==FB_BLANK_POWERDOWN && outer==FB_BLANK_UNBLANK);
assert(lid_keys_read()==0);
lid_apply(&lid,0,1,117);assert(outer==FB_BLANK_POWERDOWN);
lid_apply(&lid,-1,1,118);assert(inner==FB_BLANK_POWERDOWN);
lid_apply(&lid,1,1,119);assert(inner==FB_BLANK_UNBLANK && outer==FB_BLANK_POWERDOWN);
assert(last_panel==10 && lid_keys_read()==40);
fail_inner=1;lid_apply(&lid,0,1,120);assert(lid.opened==1 && !lid.outer_on);
fail_inner=0;lid_apply(&lid,0,0,121);assert(!lid.outer_on);
lid_apply(&lid,0,1,150);assert(lid.outer_on); /* late renderer still gets 15s */
lid_apply(&lid,0,1,166);assert(!lid.outer_on);
struct lid_state closed={.inner=10,.outer=11,.opened=-1,.keys_brightness=40};
lid_apply(&closed,0,0,200);
assert(inner==FB_BLANK_POWERDOWN && !closed.outer_on && lid_keys_read()==0);
/* Xorg unblanks fb0 while initializing, without a physical lid transition. */
inner=FB_BLANK_UNBLANK;
lid_apply(&closed,0,0,205);
lid_apply(&closed,0,1,220);
assert(inner==FB_BLANK_POWERDOWN && closed.outer_on && lid_keys_read()==0);
lid_apply(&closed,0,1,234);assert(closed.outer_on);
lid_apply(&closed,0,1,235);assert(!closed.outer_on);
lid.outer=-1;lid_apply(&lid,1,1,170);lid_apply(&lid,0,1,171);assert(!lid.outer_on);
lid_restore(&lid);assert(lid_keys_read()==40);unlink(keys_path);
puts("Clamshell panel transitions and keypad backlight: OK");return 0;
}

View file

@ -0,0 +1,44 @@
# SPDX-License-Identifier: MPL-2.0
import gzip
from pathlib import Path
import stat
import struct
import sys
import unittest
sys.path.insert(0,str(Path(__file__).parents[1]/'tools'))
from beatles_boot import read_cpio, write_cpio, add_file, pack_boot, unpack_boot
class BootTests(unittest.TestCase):
def archive(self):
entries={'.':{'fields':[1,stat.S_IFDIR|0o750,1000,1001,2,17,0,0,0,0,0,2,0],'data':b''}}
add_file(entries,'init.rc',b'on boot\n',0o640)
entries['init.rc']['fields'][2:4]=[1000,1001]
return entries
def test_repack_preserves_ownership_and_modes(self):
original=self.archive();repacked=read_cpio(write_cpio(original))
self.assertEqual(repacked['init.rc']['data'],b'on boot\n')
self.assertEqual(repacked['init.rc']['fields'][1:6],original['init.rc']['fields'][1:6])
self.assertEqual(write_cpio(original),write_cpio(original))
def test_missing_trailer_and_truncated_entry_rejected(self):
raw=gzip.decompress(write_cpio(self.archive()))
for end in (90,115,180):
with self.assertRaises(ValueError):read_cpio(gzip.compress(raw[:end]))
def test_traversal_rejected(self):
entries=self.archive();add_file(entries,'../escape',b'unsafe')
with self.assertRaises(ValueError):read_cpio(write_cpio(entries))
def test_duplicate_added_file_rejected(self):
with self.assertRaises(ValueError):add_file(self.archive(),'init.rc',b'bad')
def image(self):
header=bytearray(2048);header[:8]=b'ANDROID!'
struct.pack_into('<10I',header,8,3,0x80008000,4,0x81000000,0,0,0x80000100,2048,0,0)
return bytes(header)+b'abc'+b'\0'*2045+b'test'+b'\0'*2044+b'old signature'
def test_changed_image_drops_original_signature_and_preserves_kernel(self):
result=unpack_boot(pack_boot(self.image(),b'new ramdisk'))
self.assertEqual(result['kernel'],b'abc');self.assertEqual(result['ramdisk'],b'new ramdisk')
self.assertEqual(result['trailer'],b'')
def test_unsupported_header_and_short_payload_rejected(self):
with self.assertRaises(ValueError):unpack_boot(self.image()[:2500])
image=bytearray(self.image());struct.pack_into('<I',image,40,1)
with self.assertRaises(ValueError):unpack_boot(image)
if __name__=='__main__':unittest.main()

View file

@ -0,0 +1,38 @@
# SPDX-License-Identifier: MPL-2.0
from pathlib import Path
import socket
import subprocess
import tempfile
import unittest
SOURCE=Path(__file__).parents[1]/'devices/beatles/boot/vulpes-boot.sh'
class GuardTests(unittest.TestCase):
def run_guard(self,mode):
with tempfile.TemporaryDirectory() as directory:
root=Path(directory);base=root/'runtime';base.mkdir()
screen=root/'fb';screen.touch();ril=socket.socket(socket.AF_UNIX)
try:
ril.bind(str(root/'ril'))
start=root/'start';start.write_text('#!/bin/sh\necho "$1" >> '+str(root/'fallback')+'\n');start.chmod(0o755)
supervisor=root/'display'
action='exit 1' if mode=='launch-failed' else 'exit 0'
if mode=='ready':action='printf \'{"readyMs":100}\' > '+str(base/'rootfs/work/gaia-audit.json')
supervisor.write_text('#!/bin/sh\n'+action+'\n');supervisor.chmod(0o755)
if mode!='missing':
(base/'rootfs/opt/firefox').mkdir(parents=True);(base/'rootfs/work').mkdir()
engine=base/'rootfs/opt/firefox/firefox';engine.touch();engine.chmod(0o755)
(base/'runtime-ready').touch()
if mode=='disabled':(base/'disable-boot').touch()
script=SOURCE.read_text().replace('/data/local/vulpes-beatles',str(base)).replace('/system/bin/start',str(start)).replace('/sbin/vulpes-display',str(supervisor)).replace('/system/bin/busybox grep','grep').replace('/dev/graphics/fb0',str(screen)).replace('/dev/socket/rilproxy',str(root/'ril')).replace('sleep 1',':')
proc=subprocess.run(['sh','-c',script],capture_output=True,text=True,timeout=5)
self.assertEqual(proc.returncode,0,proc.stderr)
return (root/'fallback').exists(),(base/'stop-session').exists()
finally:ril.close()
def test_missing_runtime_falls_back(self):self.assertEqual(self.run_guard('missing'),(True,False))
def test_opt_out_falls_back(self):self.assertEqual(self.run_guard('disabled'),(True,False))
def test_failed_launch_falls_back(self):self.assertEqual(self.run_guard('launch-failed'),(True,False))
def test_stalled_gaia_requests_stop_and_falls_back(self):self.assertEqual(self.run_guard('stalled'),(True,True))
def test_ready_gaia_does_not_start_second_ui(self):self.assertEqual(self.run_guard('ready'),(False,False))
if __name__=='__main__':unittest.main()

View file

@ -0,0 +1,60 @@
# SPDX-License-Identifier: MPL-2.0
import concurrent.futures
from pathlib import Path
import socket
import subprocess
import tempfile
import unittest
class ControlRelayTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.directory=tempfile.TemporaryDirectory()
cls.root=Path(cls.directory.name)
code=(Path(__file__).parents[1]/'probes/beatles-control-client.c').read_text()
for name in ('phone-control','wifi-control'):
code=code.replace('/work/'+name+'.sock',str(cls.root/(name+'.sock')))
source=cls.root/'client.c';source.write_text(code);cls.binary=cls.root/'client'
subprocess.run(['cc','-Wall','-Wextra','-Werror',str(source),'-o',str(cls.binary)],check=True)
@classmethod
def tearDownClass(cls):cls.directory.cleanup()
def exchange(self,mode,reply):
path=self.root/(('phone' if mode=='--radio' else 'wifi')+'-control.sock')
path.unlink(missing_ok=True)
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as server:
server.bind(str(path));server.listen(1);server.settimeout(3)
def broker():
with server.accept()[0] as conn:
conn.settimeout(3);data=b''
while b'\n' not in data:data+=conn.recv(4096)
for part in reply:conn.sendall(part)
return data
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
future=pool.submit(broker)
result=subprocess.run([str(self.binary),mode],input=b'{"operation":"status"}',capture_output=True,timeout=4)
request=future.result()
return result,request
def test_radio_and_wifi_preserve_broker_envelopes(self):
for mode in ('--radio','--wifi'):
result,request=self.exchange(mode,[b'{"ok":true,',b'"result":[]}\n'])
self.assertEqual(result.returncode,0)
self.assertEqual(result.stdout,b'{"ok":true,"result":[]}\n')
self.assertEqual(request,b'{"operation":"status"}\n')
def test_disconnect_and_oversized_response_fail(self):
for reply in ([b'{"incomplete":'],[b'x'*65537]):
result,_=self.exchange('--wifi',reply)
self.assertNotEqual(result.returncode,0)
self.assertEqual(result.stdout,b'')
def test_unknown_target_and_multiple_requests_are_rejected(self):
for mode,data in [('--shell',b'{}'),('--radio',b'{}\n{}'),('--wifi',b'x'*8193)]:
result=subprocess.run([str(self.binary),mode],input=data,capture_output=True,timeout=3)
self.assertNotEqual(result.returncode,0)
self.assertEqual(result.stdout,b'')
if __name__=='__main__':unittest.main()

View file

@ -0,0 +1,63 @@
# SPDX-License-Identifier: MPL-2.0
import importlib.util
from pathlib import Path
import unittest
import tempfile
import struct
from unittest.mock import patch
spec=importlib.util.spec_from_file_location('cover',Path(__file__).parents[1]/'devices/beatles/cover-display.py')
cover=importlib.util.module_from_spec(spec);spec.loader.exec_module(cover)
class CoverTests(unittest.TestCase):
def test_animation_timing_padding_and_loop(self):
with tempfile.TemporaryDirectory() as directory:
path=Path(directory)/'animation'
first=b'\x00\xf8'*(240*240);second=b'\x1f\x00'*(240*240)
path.write_bytes(b'VULPANM1'+struct.pack('<I',2)+struct.pack('<I',50)+first+struct.pack('<I',100)+second)
animation=cover.Animation(path)
try:
self.assertEqual(animation.frame(0,480),(first,.05,0))
self.assertEqual(animation.frame(.05,480),(second,.1,1))
self.assertEqual(animation.frame(.15,480),(first,.05,0))
padded,_,_=animation.frame(0,484)
self.assertEqual(len(padded),240*484)
self.assertEqual(padded[480:484],bytes(4))
finally:animation.close()
def test_animation_rejects_invalid_and_truncated_files(self):
with tempfile.TemporaryDirectory() as directory:
path=Path(directory)/'animation'
for data in (b'',b'WRONGHDR'+struct.pack('<I',1),b'VULPANM1'+struct.pack('<I',241),
b'VULPANM1'+struct.pack('<II',1,1)+bytes(240*240*2)):
path.write_bytes(data)
with self.assertRaises(ValueError):cover.Animation(path)
def test_unknown_and_stale_are_not_full_battery_or_registered_network(self):
self.assertEqual(cover.labels({},100),('—','—'))
self.assertEqual(cover.labels({'recordedAt':1,'battery':{'level':1}},100),('—','—'))
def test_live_values_and_absent_second_sim(self):
state={'recordedAt':100,'battery':{'level':.73},'radio':[
{'available':False},{'available':True,'operator':{'longName':'Orange F'}}]}
self.assertEqual(cover.labels(state,105),('73 %','Orange F'))
def test_custom_title_and_colors(self):
title,ink,battery=cover.preferences({'vulpes.cover.title':' Mon Nokia ',
'vulpes.cover.titleColor':'#FF0000','vulpes.cover.batteryColor':'#00ff00'})
self.assertEqual(title,'Mon Nokia');self.assertEqual(ink,(1,0,0));self.assertEqual(battery,(0,1,0))
def test_invalid_preferences_and_long_title_are_safe(self):
title,ink,battery=cover.preferences({'vulpes.cover.title':['invalid'],
'vulpes.cover.titleColor':'url(file:///x)','vulpes.cover.batteryColor':None})
self.assertEqual(title,'VULPES');self.assertEqual(ink,battery)
self.assertEqual(cover.preferences({'vulpes.cover.title':'A'*30+'\n'})[0],'A'*24)
self.assertEqual(cover.preferences({'vulpes.cover.title':'\n'})[0],'VULPES')
def test_invalid_battery(self):
self.assertEqual(cover.labels({'recordedAt':100,'battery':{'level':2}},100),('—','—'))
def test_rejected_refresh_can_recover_without_stopping_gaia(self):
with patch.object(cover.fcntl,'ioctl',side_effect=[PermissionError(1,'panel asleep'),0]):
self.assertEqual(cover.present(3,bytearray(160)),{'ok':False,'errno':1})
self.assertEqual(cover.present(3,bytearray(160)),{'ok':True})
if __name__=='__main__':unittest.main()

View file

@ -0,0 +1,37 @@
# SPDX-License-Identifier: MPL-2.0
import sys,tempfile,unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0,str(Path(__file__).parents[1]/'devices/beatles'))
from hardware_control import HardwareControl
class HardwareTests(unittest.TestCase):
def setUp(self):
self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup)
self.root=Path(self.temp.name)
for name,value in [('backlight',170),('backlight-max',255),('vibrator',0)]:
(self.root/name).write_text(str(value))
self.hw=HardwareControl(self.root)
def value(self,name):return int((self.root/name).read_text())
def test_brightness_and_restore(self):
self.hw.request({'operation':'screen','enabled':True,'level':.2})
self.assertEqual(self.value('backlight'),51)
self.hw.request({'operation':'screen','enabled':False,'level':.2})
self.assertEqual(self.value('backlight'),0)
self.hw.restore();self.assertEqual(self.value('backlight'),170)
def test_vibration_sequence_cancel_and_shutdown(self):
with patch('hardware_control.time.monotonic',return_value=10) as clock:
self.hw.request({'operation':'vibrate','pattern':[120,80,200]})
self.assertEqual(self.value('vibrator'),120)
clock.return_value=10.13;self.hw.tick();self.assertEqual(self.value('vibrator'),0)
clock.return_value=10.22;self.hw.tick();self.assertEqual(self.value('vibrator'),200)
self.hw.request({'operation':'vibrate','pattern':[]})
self.assertEqual(self.value('vibrator'),0);self.assertFalse(self.hw.pattern)
self.hw.request({'operation':'vibrate','pattern':[200]});self.hw.restore()
self.assertEqual(self.value('vibrator'),0)
def test_invalid_requests_never_write(self):
for request in ({'operation':'vibrate','pattern':[10001]}, {'operation':'vibrate','pattern':[True]},
{'operation':'vibrate','pattern':[5000,5001]}, {'operation':'screen','enabled':True,'level':float('nan')},
{'operation':'screen','enabled':1,'level':.5},{'operation':'shell'},None):
with self.assertRaises(ValueError):self.hw.request(request)
self.assertEqual(self.value('backlight'),170);self.assertEqual(self.value('vibrator'),0)

View file

@ -0,0 +1,213 @@
# SPDX-License-Identifier: MPL-2.0
import importlib.util
import json
from pathlib import Path
import socket
import struct
import sys
import tempfile
import unittest
from unittest.mock import patch
DEVICE=Path(__file__).parents[1]/'devices/beatles'
sys.path.insert(0,str(DEVICE))
import radio_control as control
import sms_pdu as sms
from radio_status import Parcel
HELLO='00040A91214365870900006201812100008005E8329BFD06'
class PduTests(unittest.TestCase):
def test_standard_gsm_vector_and_unicode(self):
self.assertEqual(sms.pack7(sms.septets('hello')).hex(),'e8329bfd06')
self.assertEqual(sms.submit('+1234567890','hello'),'01000A912143658709000005E8329BFD06')
row=sms.deliver(HELLO)
self.assertEqual(row['sender'],'+1234567890');self.assertEqual(row['body'],'hello')
self.assertEqual(row['timestamp'],1792317600000)
self.assertEqual(sms.submit('123','漢').lower(),'0100038121f30008026f22')
def test_extensions_and_length_limits(self):
text='é € {} [] ^ ~ \\';values=sms.septets(text)
self.assertEqual(sms.unpack7(sms.pack7(values),len(values)),text)
sms.submit('123','A'*160)
for text in ('A'*161,'€'*81,'漢'*71):
with self.assertRaisesRegex(ValueError,'SMS_TOO_LONG'):sms.submit('123',text)
for value in ('112;reboot','*#06#','',None,'+123\n'):
with self.assertRaises(ValueError):sms.submit(value,'hello')
def test_truncated_and_unsupported_pdus(self):
for end in range(0,len(HELLO),2):
with self.assertRaises((ValueError,UnicodeError)):sms.deliver(HELLO[:end])
for pdu in ('00ff','xx','00040A9121436587097F006201812100008005E8329BFD06'):
with self.assertRaises(ValueError):sms.deliver(pdu)
def test_unicode_and_concatenation(self):
prefix='00440A912143658709000862018121000080'
row=sms.deliver(prefix+'080500030702010041')
self.assertEqual(row['body'],'A');self.assertEqual(row['concat'],(7,2,1))
with tempfile.TemporaryDirectory() as directory:
path=Path(directory)/'inbox.json';inbox=control.Inbox(path)
inbox.receive(prefix+'080500030702020042');self.assertEqual(inbox.value['inbox'],[])
inbox.receive(prefix+'080500030702010041')
self.assertEqual(inbox.value['inbox'][0]['body'],'AB')
self.assertEqual(control.Inbox(path).value['inbox'][0]['body'],'AB')
def test_durable_inbox_dedup_and_failed_save(self):
with tempfile.TemporaryDirectory() as directory:
path=Path(directory)/'inbox.json';inbox=control.Inbox(path)
with patch.object(inbox,'save',side_effect=OSError('disk full')):
with self.assertRaises(OSError):inbox.receive(HELLO)
self.assertEqual(inbox.value['seen'],{})
inbox.receive(HELLO);inbox.receive(HELLO)
self.assertEqual(len(inbox.value['inbox']),1)
self.assertEqual(path.stat().st_mode&0o777,0o600)
receipt=inbox.value['inbox'][0]['receipt'];inbox.acknowledge([receipt])
inbox.receive(HELLO);self.assertEqual(inbox.value['inbox'],[])
class ControlTests(unittest.TestCase):
def test_unanswered_request_is_not_sent_again(self):
radio=control.ControlRadio('/unused',0,None)
radio.pending={10:(9,0)}
broker=control.Broker([radio],None)
with patch.object(radio,'send') as send:
with self.assertRaisesRegex(RuntimeError,'RADIO_BUSY'):broker.exchange(9)
send.assert_not_called()
def test_call_list_timeout_is_short_and_request_stays_pending(self):
radio=control.ControlRadio('/unused',0,None);radio.sock=unittest.mock.Mock()
broker=control.Broker([radio],None)
with patch.object(control.time,'monotonic',side_effect=[0,0,1,2,3]),patch.object(broker,'step'):
with self.assertRaisesRegex(RuntimeError,'RADIO_TIMEOUT'):broker.exchange(9)
self.assertIsNone(radio.waiting)
self.assertEqual(len(radio.pending),1)
def test_call_layout_and_caller_privacy(self):
call=control.integer(1,4,2,145,0,1,0,1,0)+control.string('123456789')+control.integer(0)+control.string(None)+control.integer(0,0)
self.assertEqual(control.current_calls(Parcel(call)),[{'index':2,'state':'incoming','number':'+123456789','direction':'incoming'}])
hidden=control.integer(1,4,2,145,0,1,0,1,0)+control.string('123456789')+control.integer(1)+control.string(None)+control.integer(0,0)
self.assertEqual(control.current_calls(Parcel(hidden))[0]['number'],'')
self.assertEqual(control.current_calls(Parcel(b'')),[])
def test_late_modem_reply_does_not_complete_another_request(self):
radio=control.ControlRadio('/unused',0,None);radio.waiting=(9,20)
radio.response(9,0,Parcel(control.integer(0)),19);self.assertIsNone(radio.result)
radio.response(9,0,Parcel(control.integer(0)),20);self.assertEqual(radio.result,(True,[]))
def test_malformed_call_list_does_not_drop_radio_transport(self):
radio=control.ControlRadio('/unused',0,None);radio.waiting=(9,20)
radio.sock=unittest.mock.Mock()
radio.response(9,0,Parcel(control.integer(1)),20)
self.assertEqual(radio.result,(False,'RADIO_INVALID_CALL_LIST'))
radio.sock.close.assert_not_called()
def test_unknown_operations_do_not_reach_the_modem(self):
broker=control.Broker([],None)
with patch.object(broker,'exchange') as exchange:
for value in ({'version':1,'operation':'shell'},{'operation':'sms'},[],None):
with self.assertRaises(ValueError):broker.request(value)
exchange.assert_not_called()
def test_calls_answer_hangup_and_sms_parcels(self):
radio=control.ControlRadio('/unused',0,None);radio.state={'voice':{'connected':True}}
broker=control.Broker([radio],None)
row={'index':2,'state':'incoming','number':'123','direction':'incoming'}
with patch.object(broker,'exchange',return_value=[row]) as exchange:
calls=broker.request({'version':1,'operation':'calls'});path=calls[0]['path']
broker.request({'version':1,'operation':'hangup','path':path})
self.assertEqual(exchange.call_args.args,(12,control.integer(1,2)))
broker.request({'version':1,'operation':'answer','path':path})
self.assertEqual(exchange.call_args.args,(40,))
with self.assertRaisesRegex(ValueError,'INVALID_CALL'):
broker.request({'version':1,'operation':'hangup','path':'/stale/call/2'})
with patch.object(broker,'exchange',return_value={'state':'sent'}) as exchange:
self.assertEqual(broker.request({'version':1,'operation':'sms','number':'123','body':'hello'}),{'state':'sent'})
self.assertEqual(exchange.call_args.args,(25,control.integer(2)+control.string(None)+control.string(sms.submit('123','hello'))))
def test_second_call_is_rejected_before_dial(self):
radio=control.ControlRadio('/unused',0,None);radio.state={'voice':{'connected':True}}
broker=control.Broker([radio],None)
with patch.object(broker,'calls',return_value=[{'path':'/live/call/1'}]),patch.object(broker,'exchange') as exchange:
with self.assertRaisesRegex(RuntimeError,'RADIO_BUSY'):broker.request({'version':1,'operation':'dial','number':'123'})
exchange.assert_not_called()
def test_sms_ack_only_after_storage(self):
inbox=unittest.mock.Mock();radio=control.ControlRadio('/unused',0,inbox)
with patch.object(radio,'send') as send:
radio.unsolicited(1003,Parcel(control.string(HELLO)))
inbox.receive.assert_called_once_with(HELLO);send.assert_called_with(37,control.integer(2,1,0))
inbox.receive.side_effect=OSError('full')
radio.unsolicited(1003,Parcel(control.string(HELLO)))
send.assert_called_with(37,control.integer(2,0,255))
class RadioPowerTests(unittest.TestCase):
def broker(self):
radios=[control.ControlRadio('/unused',i,None) for i in range(2)]
for radio in radios:
radio.state={'radioState':'enabled'};radio.sock=unittest.mock.Mock()
return control.Broker(radios,None)
def test_slot_payload_readback_and_restore(self):
broker=self.broker()
def exchange(request,payload,slot=0):
self.assertEqual(request,23)
count,enabled=struct.unpack('<ii',payload);self.assertEqual(count,1)
broker.radios[slot].state['radioState']='enabled' if enabled else 'disabled'
with patch.object(broker,'exchange',side_effect=exchange) as send,patch.object(broker,'calls',return_value=[]):
self.assertEqual(broker.request({'version':1,'operation':'radioPower','slot':1,'enabled':False}),
{'slot':1,'radioState':'disabled'})
self.assertEqual(broker.radios[0].state['radioState'],'enabled')
send.assert_called_once_with(23,control.integer(1,0),slot=1)
broker.restore_power()
self.assertEqual(broker.radios[1].state['radioState'],'enabled')
def test_idempotent_power_does_not_send(self):
broker=self.broker()
with patch.object(broker,'exchange') as send:
broker.radio_power(0,True);send.assert_not_called()
def test_calls_invalid_values_and_unknown_state_block_power(self):
broker=self.broker()
with patch.object(broker,'exchange') as send,patch.object(broker,'calls',return_value=[{'state':'active'}]):
with self.assertRaisesRegex(RuntimeError,'RADIO_BUSY'):broker.radio_power(0,False)
for slot,enabled in [(False,False),(2,False),(0,0),(0,'false')]:
with self.assertRaisesRegex(ValueError,'INVALID_REQUEST'):broker.radio_power(slot,enabled)
broker.radios[0].state['radioState']='unknown'
with self.assertRaisesRegex(RuntimeError,'RADIO_NOT_READY'):broker.radio_power(0,False)
send.assert_not_called()
def test_acknowledgement_without_state_change_fails(self):
broker=self.broker()
with patch.object(broker,'exchange'),patch.object(broker,'calls',return_value=[]),patch.object(control.time,'monotonic',side_effect=[0,9]):
with self.assertRaisesRegex(RuntimeError,'RADIO_READBACK_FAILED'):broker.radio_power(0,False)
self.assertEqual(broker.original_power,{0:True})
class AudioTests(unittest.TestCase):
def test_call_mode_routing_and_restore(self):
from call_audio import CallAudio
audio=CallAudio();state={'mode':0,'route':0,'muted':0};commands=[]
def transaction(service,method,*values):
commands.append((service,method,values))
if (service,method)==('policy',6):return state['route']
if (service,method)==('flinger',18):return state['muted']
if (service,method)==('policy',3):state['mode']=values[0]
elif (service,method)==('policy',5):state['route']=values[1]
elif (service,method)==('flinger',17):state['muted']=values[0]
else:raise AssertionError('Unexpected audio transaction')
return 0
with patch.object(audio,'transact',side_effect=transaction):
audio.update([{'state':'incoming'}]);self.assertEqual(state['mode'],1)
audio.update([{'state':'active'}]);self.assertEqual(state['mode'],2)
self.assertTrue(audio.request({'operation':'set','muted':True,'speaker':True})['speaker'])
audio.restore();self.assertEqual(state,{'mode':0,'route':0,'muted':0})
def test_audio_failure_does_not_throw_from_call_tracking(self):
from call_audio import CallAudio
audio=CallAudio()
with patch.object(audio,'transact',side_effect=OSError('service missing')):
audio.update([{'state':'active'}]);self.assertFalse(audio.available)
with self.assertRaisesRegex(RuntimeError,'CALL_AUDIO_UNAVAILABLE'):audio.request({'operation':'status'})
class RilRequestSafetyTests(unittest.TestCase):
def test_call_list_uses_android_request_nine(self):
broker=control.Broker([],None)
with patch.object(broker,'exchange',return_value=[]) as exchange:
self.assertEqual(broker.calls(),[])
exchange.assert_called_once_with(9)
def test_pin_puk_and_identity_requests_never_reach_socket(self):
radio=control.ControlRadio('/unused',0,None)
radio.sock=unittest.mock.Mock()
for request in (2,3,4,5,6,7,8,11,28,38,39,43):
with self.assertRaisesRegex(ValueError,'RADIO_INVALID_REQUEST'):radio.send(request)
radio.sock.sendall.assert_not_called()
self.assertEqual(control.CONTROL_REQUESTS,{9,10,12,23,25,37,40})
if __name__=='__main__':unittest.main()

View file

@ -0,0 +1,65 @@
# SPDX-License-Identifier: MPL-2.0
import importlib.util
from pathlib import Path
import socket
import struct
import tempfile
import unittest
from unittest.mock import patch, Mock
spec=importlib.util.spec_from_file_location('beatles_radio',Path(__file__).resolve().parents[1]/'devices/beatles/radio_status.py')
radio=importlib.util.module_from_spec(spec);spec.loader.exec_module(radio)
def integers(*values):return struct.pack('<'+'i'*len(values),*values)
def string(value):
if value is None:return integers(-1)
data=integers(len(value))+value.encode('utf-16-le')+b'\0\0'
return data+b'\0'*((-len(data))%4)
class RadioTest(unittest.TestCase):
def test_registration_does_not_publish_cell_location(self):
payload=integers(4)+b''.join(map(string,['5','ffff','123456','14']))
result=radio.decode(20,radio.Parcel(payload))['voice']
self.assertEqual(result['type'],'lte');self.assertTrue(result['connected']);self.assertTrue(result['roaming'])
self.assertNotIn('123456',str(result));self.assertNotIn('ffff',str(result))
def test_lte_signal_and_registration_without_data_bearer(self):
values=[99,0,0,0,0,0,0,99,95,0,0,0,0]
self.assertEqual(radio.decode(19,radio.Parcel(integers(*values)))['signal'],47)
values[8]=2147483647
self.assertIsNone(radio.decode(19,radio.Parcel(integers(*values)))['signal'])
payload=integers(4)+b''.join(map(string,['1','','','14']))
state=radio.decode(21,radio.Parcel(payload))['data']
self.assertEqual(state['state'],'registered');self.assertFalse(state['connected'])
def test_sim_absent_and_locked(self):
self.assertEqual(radio.decode(1,radio.Parcel(integers(0,0,-1,-1,-1,0))),{'sim':{'present':False,'cardState':'absent'}})
payload=integers(1,0,0,-1,-1,1,2,2,0)+string('PRIVATE_AID')+string('SIM')+integers(0,1,0)
result=radio.decode(1,radio.Parcel(payload))
self.assertEqual(result,{'sim':{'present':True,'cardState':'pinRequired'}})
def test_bounds_and_unknown_signal(self):
self.assertIsNone(radio.decode(19,radio.Parcel(integers(99)))['signal'])
for data in [b'',integers(999999),integers(2)+b'a\0']:
with self.assertRaises(ValueError):radio.Parcel(data).string()
with self.assertRaises(ValueError):radio.decode(25,radio.Parcel(b''))
def test_partial_frame_and_disconnect_drop_state(self):
client,server=socket.socketpair()
r=radio.Radio('/unused',0);r.sock=client;r.pending={3:(1,0)}
body=integers(0,3,0,0,0,-1,-1,-1,0);frame=struct.pack('>I',len(body))+body
server.sendall(frame[:7]);r.receive();self.assertFalse(r.state['available'])
server.sendall(frame[7:]);r.receive();self.assertFalse(r.state['sim']['present'])
r.close();server.close();self.assertEqual(r.state,{'slot':0,'available':False,'radioState':'unknown'})
def test_handover_grace_and_bounded_reconnect_backoff(self):
r=radio.Radio('/unused',0);r.sock=Mock();r.serial=1;r.last_poll=100;r.pending={1:(9,90)}
with patch.object(radio.time,'monotonic',return_value=110):
r.poll();self.assertIsNotNone(r.sock)
with patch.object(radio.time,'monotonic',return_value=140):
r.poll();self.assertIsNone(r.sock);self.assertEqual(r.retry_at,145)
r.close();self.assertEqual(r.retry_at,150)
for _ in range(8):r.close()
self.assertEqual(r.retry_at,200)
def test_battery_unknown_is_not_empty(self):
with tempfile.TemporaryDirectory() as root:
p=Path(root);self.assertIsNone(radio.battery(p))
(p/'capacity').write_text('73');(p/'status').write_text('Discharging')
self.assertEqual(radio.battery(p),{'level':.73,'charging':False})
if __name__=='__main__':unittest.main()

View file

@ -0,0 +1,20 @@
import importlib.util
from pathlib import Path
import unittest
spec=importlib.util.spec_from_file_location('sync',Path(__file__).resolve().parents[1]/'tools/sync-beatles-gaia.py')
sync=importlib.util.module_from_spec(spec);spec.loader.exec_module(sync)
class SharedGaiaSync(unittest.TestCase):
def test_incremental_changes_and_removal(self):
self.assertEqual(sync.changes({'host/shell.js':'new','assets/a':'same','overrides/new.js':'new'},
{'host/shell.js':'old','assets/a':'same','overrides/old.js':'old'}),
(['host/shell.js','overrides/new.js'],['overrides/old.js']))
def test_profiles_and_escape_cannot_be_deleted(self):
for path in ('profiles/user.json','../profile','host/../../profile','/data/profile','hostile/file'):
with self.subTest(path=path),self.assertRaises(ValueError):sync.changes({}, {path:'digest'})
def test_first_sync_and_no_change(self):
files={'host/shell.js':'digest','release.json':'digest'}
self.assertEqual(sync.changes(files,{}),(sorted(files),[]))
self.assertEqual(sync.changes(files,files),([],[]))
if __name__=='__main__':unittest.main()

View file

@ -0,0 +1,26 @@
import importlib.util
from pathlib import Path
import unittest
ROOT = Path(__file__).resolve().parents[2]
spec = importlib.util.spec_from_file_location(
'package_beatles', ROOT / 'tundra/tools/package-beatles-build.py')
package = importlib.util.module_from_spec(spec)
spec.loader.exec_module(package)
class BeatlesT9Package(unittest.TestCase):
def test_all_offline_lexicons_are_included_at_runtime_path(self):
files = {}
package.add_beatles_t9(files)
self.assertEqual(len(files), 42)
self.assertEqual(
set(files),
{f'opt/vulpes/tundra/devices/beatles/t9/{locale}.tsv'
for locale in (path.stem for path in
(ROOT / 'tundra/devices/beatles/t9').glob('*.tsv'))})
self.assertTrue(all(path.is_file() for path in files.values()))
if __name__ == '__main__':
unittest.main()

View file

@ -0,0 +1,41 @@
# SPDX-License-Identifier: MPL-2.0
import sys
from pathlib import Path
import unittest
from unittest.mock import Mock,patch
sys.path.insert(0,str(Path(__file__).parents[1]/'devices/beatles'))
from stream_volume import StreamVolume
class VolumeTests(unittest.TestCase):
def test_route_readback_and_restoration(self):
volume=StreamVolume();state={(0,1):3,(0,2):5}
def transaction(method,*args):
if method==24:return [3]
stream,device=args[0],args[-1]
if method==17:return [state[stream,device],0]
if method==16:state[stream,device]=args[1];return [0]
raise AssertionError(method)
volume.transact=transaction
self.assertEqual(volume.request({'stream':'telephony','level':.4})['index'],2)
self.assertEqual(state,{(0,1):2,(0,2):2})
volume.request({'stream':'telephony','level':.8})
volume.restore();self.assertEqual(state,{(0,1):3,(0,2):5})
def test_invalid_request_never_touches_hardware(self):
volume=StreamVolume();volume.transact=Mock()
for value in (None,{}, {'stream':'shell','level':.5}, *({'stream':'content','level':n} for n in (True,-1,2,'1',float('nan'),float('inf')))):
with self.assertRaises(ValueError):volume.request(value)
volume.transact.assert_not_called()
def test_readback_failure_is_reported(self):
volume=StreamVolume();volume.transact=Mock(side_effect=[[2],[3,0],[0],[3,0]])
with self.assertRaisesRegex(RuntimeError,'CALL_AUDIO_UNAVAILABLE'):
volume.request({'stream':'content','level':.5})
def test_parcel_formats(self):
volume=StreamVolume()
for text,expected in (("Result: Parcel(00000008 00000000 '........')\n",[8,0]),
("Result: Parcel(\n 0x00000000: 00000008 00000000 '........'\n)\n",[8,0])):
with patch('stream_volume.subprocess.run',return_value=Mock(returncode=0,stdout=text)):
self.assertEqual(volume.transact(17,3,2),expected)

View file

@ -0,0 +1,102 @@
# SPDX-License-Identifier: MPL-2.0
import importlib.util
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
spec=importlib.util.spec_from_file_location('beatles_wifi',Path(__file__).parents[1]/'devices/beatles/wifi_control.py')
wifi=importlib.util.module_from_spec(spec);spec.loader.exec_module(wifi)
class WifiTests(unittest.TestCase):
def fixture(self):
obj=wifi.Wifi.__new__(wifi.Wifi)
obj.last_scan=[{'id':'aa:bb:cc:dd:ee:ff','ssid':'Test "é"','security':'wpa-psk'}]
obj.enabled=lambda:True
obj.known=lambda:[]
obj.stopping=False
obj.lease=lambda state:None
return obj
def test_scan_decodes_ssids_and_does_not_offer_enterprise_as_psk(self):
data='bssid / frequency / signal level / flags / ssid\n'
data+='aa:bb:cc:dd:ee:ff\t2412\t-45\t[WPA2-PSK-CCMP][ESS]\tCaf\\xc3\\xa9\\\\Test\n'
data+='00:11:22:33:44:55\t5200\t-80\t[WPA2-EAP-CCMP]\tOffice\n'
rows=wifi.parse_scan(data)
self.assertEqual(rows[0]['ssid'],'Café\\Test')
self.assertEqual(rows[0]['signal'],100)
self.assertEqual(rows[1]['security'],'unsupported')
self.assertEqual(wifi.parse_scan('header\nnot-a-bssid\t2412\t-20\t[ESS]\tfake'),[])
def test_invalid_password_cannot_create_or_mutate_network(self):
with patch.object(wifi,'command') as command:
with self.assertRaisesRegex(ValueError,'WIFI_INVALID_PASSWORD'):
self.fixture().connect({'operation':'connect','id':'aa:bb:cc:dd:ee:ff','password':'short'})
command.assert_not_called()
def test_forget_rejects_command_injection(self):
with patch.object(wifi,'command') as command:
with self.assertRaisesRegex(ValueError,'WIFI_INVALID_REQUEST'):
self.fixture().request({'operation':'forget','id':'0\nREMOVE_NETWORK all'})
command.assert_not_called()
def test_connect_encodes_ssid_and_passphrase_before_supplicant(self):
obj=self.fixture();obj.state=lambda:{'state':'connected','network':{'ssid':'Test "é"'}}
calls=[]
def command(text,required=True):
calls.append(text)
return '7' if text=='ADD_NETWORK' else 'OK'
with patch.object(wifi,'command',command),patch.object(wifi,'android',return_value='ok'):
obj.connect({'operation':'connect','id':'aa:bb:cc:dd:ee:ff','password':'test-only-password'})
self.assertIn('SET_NETWORK 7 ssid '+'Test "é"'.encode().hex(),calls)
psk=next(c for c in calls if c.startswith('SET_NETWORK 7 psk ')).split()[-1]
self.assertEqual(len(psk),64)
self.assertNotIn('test-only-password',' '.join(calls))
self.assertIn('SAVE_CONFIG',calls)
def test_saved_network_reuses_credentials_without_reading_password(self):
obj=self.fixture();obj.known=lambda:[{'id':'3','ssid':'Test "é"','security':'wpa-psk'}]
obj.state=lambda:{'state':'connected','network':{'ssid':'Test "é"'}}
with patch.object(wifi,'command',return_value='OK') as command,patch.object(wifi,'android',return_value='ok'):
obj.connect({'operation':'connect','id':'aa:bb:cc:dd:ee:ff'})
self.assertEqual([call.args[0] for call in command.call_args_list],['SELECT_NETWORK 3'])
def test_save_failure_rolls_back_new_profile(self):
obj=self.fixture();obj.state=lambda:{'state':'connected','network':{'ssid':'Test "é"'}}
calls=[]
def command(text,required=True):
calls.append(text)
return '7' if text=='ADD_NETWORK' else 'FAIL' if text=='SAVE_CONFIG' else 'OK'
with patch.object(wifi,'command',command),patch.object(wifi,'android',return_value='ok'):
with self.assertRaisesRegex(RuntimeError,'WIFI_SAVE_FAILED'):
obj.connect({'operation':'connect','id':'aa:bb:cc:dd:ee:ff','password':'test-only-password'})
self.assertIn('REMOVE_NETWORK 7',calls)
self.assertIn('ENABLE_NETWORK all',calls)
class LeaseTests(unittest.TestCase):
def test_dhcp_service_gets_dynamic_interface_argument(self):
obj=wifi.Wifi.__new__(wifi.Wifi)
obj.last_lease=0;obj.lease_network=None;obj.route=None
state={'state':'connected','network':{'ssid':'Test','savedId':'0'}}
with patch.object(wifi,'android',return_value='stopped') as android,patch.object(wifi.time,'monotonic',return_value=100):
obj.lease(state)
commands=[call.args for call in android.call_args_list]
self.assertIn(('setprop','ctl.start','dhcpcd_wlan0:wlan0'),commands)
self.assertNotIn(('setprop','ctl.restart','dhcpcd_wlan0'),commands)
def test_routes_are_restored_when_link_down_removed_cached_routes(self):
obj=wifi.Wifi.__new__(wifi.Wifi)
obj.last_lease=0;obj.lease_network=('Test','0');obj.rule_owned=True
obj.route=('192.0.2.0/24','192.0.2.1')
properties={'dhcp.wlan0.result':'ok','dhcp.wlan0.ipaddress':'192.0.2.3',
'dhcp.wlan0.gateway':'192.0.2.1','dhcp.wlan0.mask':'255.255.255.0'}
def native(*args,**kwargs):
return properties.get(args[1],'') if args[0]=='getprop' else ''
with patch.object(wifi,'android',side_effect=native) as android,patch.object(wifi.time,'monotonic',return_value=100):
obj.lease({'state':'connected','network':{'ssid':'Test','savedId':'0'}})
commands=[call.args for call in android.call_args_list]
self.assertIn(('ip','route','add','default','via','192.0.2.1','dev','wlan0','table','21052'),commands)
self.assertEqual([c for c in commands if c[:3]==('ip','route','flush')],
[('ip','route','flush','table','21052')])
if __name__=='__main__':unittest.main()

View file

@ -22,7 +22,9 @@ class Buttons(unittest.TestCase):
def test_vibration_pattern_progresses_without_blocking_and_can_be_cancelled(self):
with tempfile.TemporaryDirectory() as d,patch.object(b,'VIBRATOR',Path(d)),patch.object(b.time,'monotonic',return_value=10):
(Path(d)/'trigger').write_text('[transient]')
v=b.Vibration();v.start([200,100,300])
self.assertEqual((Path(d)/'trigger').read_text(),'none')
self.assertEqual((Path(d)/'brightness').read_text(),'255')
self.assertEqual(v.deadline,10.2)
v.advance(10.1);self.assertEqual((Path(d)/'brightness').read_text(),'255')
@ -32,3 +34,18 @@ class Buttons(unittest.TestCase):
v.start([200]);v.start([]);self.assertIsNone(v.deadline);self.assertEqual((Path(d)/'brightness').read_text(),'0')
for pattern in [None,[-1],[True],[10001],[5000,5001],[1]*33]:
with self.assertRaises(ValueError):v.start(pattern)
def test_wake_recovery_waits_for_gaia_and_recovers_if_unresponsive(self):
with tempfile.TemporaryDirectory() as d,patch.object(b,'BACKLIGHT',Path(d)):
(Path(d)/'max_brightness').write_text('1000')
(Path(d)/'brightness').write_text('0')
recovery=b.WakeRecovery()
with patch.object(b,'wake_recovery',recovery):
recovery.arm(10);recovery.advance(11)
self.assertEqual((Path(d)/'brightness').read_text(),'0')
b.control({'operation':'screen','enabled':True,'level':.3})
recovery.advance(12)
self.assertEqual((Path(d)/'brightness').read_text(),'300')
b.control({'operation':'screen','enabled':False,'level':.3})
recovery.arm(20);recovery.advance(21.6)
self.assertEqual((Path(d)/'brightness').read_text(),'500')

View file

@ -0,0 +1,31 @@
import importlib.util
from pathlib import Path
import json
import tempfile
import unittest
from unittest.mock import patch
spec=importlib.util.spec_from_file_location('clock',Path(__file__).parents[1]/'runtime/clock-state.py')
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
class ClockState(unittest.TestCase):
def test_preserves_time_plus_powered_off_elapsed_without_setting_rtc(self):
with tempfile.TemporaryDirectory() as d, patch.object(m,'STATE',Path(d)/'time.json'), patch.object(m,'RTC',Path(d)/'rtc'):
m.RTC.write_text('1000')
with patch.object(m.time,'time',return_value=1792340000): m.save()
m.RTC.write_text('1600')
with patch.object(m.time,'time',return_value=1792000000), patch.object(m.time,'clock_settime') as write:
self.assertTrue(m.restore());write.assert_called_once_with(m.time.CLOCK_REALTIME,1792340600)
self.assertEqual(m.RTC.read_text(),'1600')
self.assertEqual(m.STATE.stat().st_mode & 0o777,0o600)
def test_never_rewinds_valid_wall_time(self):
with tempfile.TemporaryDirectory() as d,patch.object(m,'STATE',Path(d)/'time.json'),patch.object(m,'rtc_seconds',return_value=None):
m.STATE.write_text(json.dumps({'epoch':1792340000,'rtc':1000}))
with patch.object(m.time,'time',return_value=1792350000),patch.object(m.time,'clock_settime') as write:
self.assertFalse(m.restore());write.assert_not_called()
def test_corrupt_state_or_reset_rtc_does_not_invent_elapsed_time(self):
with tempfile.TemporaryDirectory() as d,patch.object(m,'STATE',Path(d)/'time.json'),patch.object(m,'rtc_seconds',return_value=10),patch.object(m.time,'time',return_value=100):
m.STATE.write_text('invalid')
self.assertFalse(m.restore())
m.STATE.write_text(json.dumps({'epoch':1792340000,'rtc':1000}))
with patch.object(m.time,'clock_settime') as write:
self.assertTrue(m.restore());write.assert_called_once_with(m.time.CLOCK_REALTIME,1792340000)

View file

@ -5,6 +5,8 @@ from unittest.mock import patch
spec=importlib.util.spec_from_file_location('mobile',Path(__file__).parents[1]/'runtime/mobile-settings.py')
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
class NetworkClock(unittest.TestCase):
def setUp(self):
self.checkpoint=patch.object(m,'checkpoint_clock');self.checkpoint.start();self.addCleanup(self.checkpoint.stop)
def test_monotonic_age_is_added_to_nitz(self):
payload={'data':[{'UTC':{'data':1791836110},'Received':{'data':530}}]}
with patch.object(m.subprocess,'run',return_value=SimpleNamespace(returncode=0,stdout=json.dumps(payload))),patch.object(m.time,'monotonic',return_value=550):

View file

@ -129,6 +129,18 @@ on boot
'mount ext4 /dev/mmcblk0p72 /data','mount tmpfs /dev/block/x /data'):
self.assertFalse(policy.command_allowed(line.split()),line)
def test_network_config_on_empty_overlay_creates_firewall_aliases(self):
import tempfile
spec = importlib.util.spec_from_file_location('native_prepare', ROOT/'runtime/prepare-native.py')
native = importlib.util.module_from_spec(spec); spec.loader.exec_module(native)
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
(root/'opt/vulpes/trial').mkdir(parents=True)
(root/'opt/vulpes/trial/session.py').touch()
native.prepare(root, network=True, hardware=True)
for name in ('iptables', 'ip6tables'):
self.assertEqual((root/'usr/sbin'/name).readlink(), Path(name+'-legacy'))
def test_interactive_boot_checks_are_opt_in(self):
import tempfile
spec = importlib.util.spec_from_file_location('native_prepare', ROOT/'runtime/prepare-native.py')

Some files were not shown because too many files have changed in this diff Show more