Release Vulpes OS 2.7-preview.24

This commit is contained in:
OverSu 2026-11-04 12:52:20 +01:00
commit 97ef99f827
135 changed files with 7717 additions and 2524 deletions

View file

@ -9,7 +9,8 @@ if [ "$(date +%s)" -lt "${BUILD_EPOCH:-0}" ]; then date -s "@$BUILD_EPOCH"; fi
trap 'echo TUNDRA_NATIVE_SETUP_FAILED; while :; do /bin/busybox sleep 5; done' EXIT
LOWER=/installed/home/droidian/vulpes-tundra-tests/runtime-reference-20260925/root
if [ -n "${SYSTEM_IMAGE:-}" ]; then
test "$(sha256sum "$SYSTEM_IMAGE" | cut -d' ' -f1)" = "$SYSTEM_IMAGE_SHA"
test -f "$SYSTEM_IMAGE" && test ! -L "$SYSTEM_IMAGE"
test "$(stat -c %s "$SYSTEM_IMAGE")" = "$SYSTEM_IMAGE_BYTES"
mkdir -p /run/tundra-system
mount -t ext4 -o loop,ro,noload "$SYSTEM_IMAGE" /run/tundra-system
LOWER=/run/tundra-system

View file

@ -45,7 +45,19 @@ done
/sbin/dropbear -E -s -j -k -p 10.15.19.82:2222 -r /etc/tundra-hostkey -P /run/dropbear.pid
echo TUNDRA_USB_SSH_READY
# No LVM activation, fsck, journal replay, resizing, or writes to a block device.
if [ -b /dev/mmcblk0p72 ] && [ "$(dd if=/dev/mmcblk0p72 bs=1048576 count=1 2>/dev/null | sha256sum | cut -d' ' -f1)" = "$EXPECTED_STORAGE_HASH" ]; then
if [ "${RAW_USERDATA_EXT4:-0}" = 1 ]; then
# Ext4 updates mutable mount counters and timestamps in the first MiB; use
# the filesystem UUID at the fixed superblock offset as the stable identity.
actual_uuid=$(dd if=/dev/mmcblk0p72 bs=1 skip=1128 count=16 2>/dev/null | od -An -tx1 | tr -d ' \n')
if [ -b /dev/mmcblk0p72 ] && [ "$actual_uuid" = "$EXPECTED_STORAGE_UUID_HEX" ]; then
mkdir -p /installed
if [ "${TUNDRA_USERDATA_RW:-0}" = 1 ]; then
mount -t ext4 -o rw /dev/mmcblk0p72 /installed
else
mount -t ext4 -o ro,noload /dev/mmcblk0p72 /installed
fi
fi
elif [ -b /dev/mmcblk0p72 ] && [ "$(dd if=/dev/mmcblk0p72 bs=1048576 count=1 2>/dev/null | sha256sum | cut -d' ' -f1)" = "$EXPECTED_STORAGE_HASH" ]; then
mkdir -p /dev/mapper /installed
/sbin/dmsetup create tundra-installed-ro --readonly --table '0 104448000 linear /dev/mmcblk0p72 329728'
/sbin/dmsetup mknodes

View file

@ -5,8 +5,11 @@
#define _start unused_stage0_start
#include "init.c"
#undef _start
#ifndef TUNDRA_WATCHDOG_SECONDS
#define TUNDRA_WATCHDOG_SECONDS 600
#endif
__attribute__((noreturn)) void _start(void) {
long remaining[2] = {600, 0};
long remaining[2] = {TUNDRA_WATCHDOG_SECONDS, 0};
while (call(NR_nanosleep, (long)remaining, (long)remaining, 0, 0, 0) == -4) {}
say("TUNDRA_TRIAL_DEADLINE_REBOOT\n");
call(NR_reboot, 0xfee1dead, 672274793, 0x01234567, 0, 0);

View file

@ -6,7 +6,7 @@
"abi": "armeabi-v7a",
"soc": "MSM8905",
"board": "msm8909",
"taVariant": null,
"taVariant": "TA-1175",
"observedFirmware": {
"kaios": "2.5.2.2",
"gecko": "48.0a2",
@ -128,7 +128,7 @@
"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",
"path": "tundra/out/beatles/boot-trial-20261022-r7",
"signed": false,
"bootTested": false,
"standalone": false,

View file

@ -1,4 +1,5 @@
import {tundraRadio} from 'resource://vulpes/host/Tundra.sys.mjs';
import {Runtime} from 'resource://vulpes/host/Runtime.sys.mjs';
import {setTimeout} from 'resource://gre/modules/Timer.sys.mjs';
const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms));
@ -164,6 +165,62 @@ async function run(win) {
await key('Escape');
}
}
// Exercise the real Tundra worker with the exact 320x240 preview mode
// used by KaiOS on beatles. Keep the sample in memory and release Camera1
// immediately; this is a smoke check, not a retained photo or recording.
const cameraActor={};
let cameraSession;
try {
const metadata=await Runtime.platform.nativeCamera.request(cameraActor,{action:'start',camera:'back'});
cameraSession=metadata.session;
const frame=await Runtime.platform.nativeCamera.request(cameraActor,{action:'frame',session:cameraSession});
const data=new Uint8Array(frame.data);
const pixels=metadata.width*metadata.height;
let sum=0,min=255,max=0;
for(let i=0;i<Math.min(pixels,data.length);i++) { const value=data[i];sum+=value;min=Math.min(min,value);max=Math.max(max,value); }
report.cameraPreview={width:metadata.width,height:metadata.height,sequence:frame.sequence,
bytes:data.length,yMean:data.length?Number((sum/Math.min(pixels,data.length)).toFixed(2)):null,yMin:data.length?min:null,yMax:data.length?max:null,
passed:data.length===pixels*3/2 && max>32};
const picture=await Runtime.platform.nativeCamera.request(cameraActor,{action:'picture',session:cameraSession,size:{width:1600,height:1200}});
const jpeg=new Uint8Array(picture.data);
report.cameraPicture={bytes:jpeg.length,jpeg:jpeg[0]===0xff&&jpeg[1]===0xd8,passed:jpeg.length>4096&&jpeg[0]===0xff&&jpeg[1]===0xd8};
} catch(error) {
report.cameraPreview={passed:false,error:error.message || String(error)};
} finally {
if(cameraSession!==undefined) {
try { await Runtime.platform.nativeCamera.request(cameraActor,{action:'stop',session:cameraSession}); }
catch(error) { report.cameraPreview={...report.cameraPreview,stopError:error.message || String(error)}; }
}
}
report.cameraApps=Runtime.apps.filter(app=>/camera|photo/i.test(app.id+' '+app.name+' '+app.origin)).map(app=>({id:app.id,name:app.name,origin:app.origin,manifestURL:app.manifestURL}));
try {
// Match the URL-based launcher used by the other Gaia app audits. The
// app registry's internal ID is not stable across Gaia manifests.
await evaluate('system',`return await VulpesCompat.call('apps.launch',{manifestURL:'http://camera.localhost:8765/manifest.webapp'});`);
for(let i=0;i<120&&!find(system,'camera');i++)await sleep(250);
if(find(system,'camera')) {
for(let i=0;i<120;i++) {
const state=await evaluate('camera',"return {ready:document.readyState==='complete',app:typeof app!=='undefined'&&!!app.camera,busy:typeof app!=='undefined'&&!!app.camera?.isBusy};");
if(state.ready&&state.app&&(!state.busy||i>=119))break;
await sleep(250);
}
await sleep(2500);
report.cameraApp=await evaluate('camera',"var v=document.querySelector('.js-video'),c=document.querySelector('canvas[data-vulpes-native-preview]'),a=typeof app==='undefined'?null:app,cam=a?.camera,stream=cam?.mozCamera,canvasLuma=null;try{if(c){const t=document.createElement('canvas');t.width=80;t.height=60;const x=t.getContext('2d',{willReadFrequently:true});x.drawImage(c,0,0,80,60);const p=x.getImageData(0,0,80,60).data;let sum=0,min=255,max=0;for(let i=0;i<p.length;i+=4){const y=(77*p[i]+150*p[i+1]+29*p[i+2])>>8;sum+=y;min=Math.min(min,y);max=Math.max(max,y);}canvasLuma={mean:Number((sum/4800).toFixed(1)),min,max};}}catch(e){canvasLuma={error:e.message||String(e)};}return {loaded:document.readyState==='complete',readyState:document.readyState,url:location.href,title:document.title,viewfinder:!!v,previewCanvas:!!c,canvasSize:c?[c.width,c.height]:null,canvasLuma,videoHidden:v?getComputedStyle(v).display==='none':false,documentHidden:document.hidden,visibility:document.visibilityState,errorText:document.querySelector('.overlay[data-type=\"request-fail\"] #overlay-text')?.textContent,bodyText:document.body?.innerText.slice(0,240),appGlobal:!!a,cameraObject:!!cam,cameraBusy:cam?.isBusy,cameraReady:!!stream&&!cam.isBusy,mozCamera:!!stream,previewFrames:stream?.vulpesTiming?.frames||0,previewClosed:stream?.vulpesClosed,mode:cam?.mode||null,cameraList:cam?.cameraList};");
if(report.cameraApp.cameraReady) {
try {
await evaluate('camera',"await app.camera.mozCamera.vulpesCameraControl({action:'flash',mode:'torch'});return true;");
await sleep(1500);
report.cameraApp.torchCanvas=await evaluate('camera',"const c=document.querySelector('canvas[data-vulpes-native-preview]'),t=document.createElement('canvas');t.width=80;t.height=60;const x=t.getContext('2d',{willReadFrequently:true});x.drawImage(c,0,0,80,60);const p=x.getImageData(0,0,80,60).data;let sum=0,min=255,max=0;for(let i=0;i<p.length;i+=4){const y=(77*p[i]+150*p[i+1]+29*p[i+2])>>8;sum+=y;min=Math.min(min,y);max=Math.max(max,y);}return {mean:Number((sum/4800).toFixed(1)),min,max};");
} catch(error) { report.cameraApp.torchCanvas={error:error.message||String(error)}; }
finally {
try { await evaluate('camera',"await app.camera.mozCamera.vulpesCameraControl({action:'flash',mode:'off'});return true;"); }
catch(error) { report.cameraApp.torchOffError=error.message||String(error); }
}
try { report.cameraApp.picture=await evaluate('camera',"const b=await app.camera.mozCamera.takePicture(),d=new Uint8Array(await b.arrayBuffer());let bitmap=null;try{bitmap=await createImageBitmap(b);const c=document.createElement('canvas');c.width=80;c.height=60;const x=c.getContext('2d',{willReadFrequently:true});x.drawImage(bitmap,0,0,c.width,c.height);const p=x.getImageData(0,0,c.width,c.height).data;let sum=0,min=255,max=0;for(let i=0;i<p.length;i+=4){const y=(77*p[i]+150*p[i+1]+29*p[i+2])>>8;sum+=y;min=Math.min(min,y);max=Math.max(max,y);}return {bytes:b.size,type:b.type,jpeg:d[0]===255&&d[1]===216,width:bitmap.width,height:bitmap.height,lumaMean:Number((sum/(p.length/4)).toFixed(1)),lumaMin:min,lumaMax:max};}finally{bitmap?.close();}"); }
catch(error) { report.cameraApp.picture={error:error.message||String(error)}; }
}
} else report.cameraApp={loaded:false,launch:'sent',apps:report.cameraApps};
} catch(error) { report.cameraApp={loaded:false,error:error.message||String(error),apps:report.cameraApps}; }
}catch(error){report.error=String(error);report.stack=error.stack;}
await save();
}

View file

@ -50,8 +50,8 @@ 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')
def unprivileged(oom_score=800):
Path('/proc/self/oom_score_adj').write_text(str(oom_score))
# Android's paranoid-network kernel requires inet even for private loopback.
os.setgroups([3003])
os.setgid(2000)
@ -64,15 +64,15 @@ env.update(HOME='/work',TMPDIR='/tmp',DISPLAY=':0',MOZ_ENABLE_WAYLAND='0',
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
duration=1780 if os.environ.get("VULPES_TRIAL_MANUAL")=="1" else 220
processes=[]
handles=[]
try:
def start(command,log,drop=False):
def start(command,log,drop=False,oom_score=800):
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)
preexec_fn=(lambda: unprivileged(oom_score)) if drop else None)
processes.append(proc)
return proc
xorg=start(['/usr/lib/xorg/Xorg',':0','vt1','-config','/etc/X11/xorg.conf',
@ -101,7 +101,7 @@ 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)
'-chrome','chrome://vulpes/content/shell.xhtml'],'gecko.log',True,oom_score=200)
begin=time.monotonic()
last_key_sequence=0
last_memory_sample=0

View file

@ -0,0 +1,42 @@
{
"recordedAt": "2026-09-24T18:33:08.938157+00:00",
"device": "sargo",
"rawPartitions": true,
"restorationTested": false,
"scope": "Boot, DTBO and vbmeta only; excludes userdata, vendor and system",
"files": {
"boot_a.img": {
"source": "/dev/mmcblk0p13",
"bytes": 67108864,
"sha256": "df961004d4d455f62635c080a3f2543cdf893a683626a0fb4da9104f69f70699"
},
"boot_b.img": {
"source": "/dev/mmcblk0p14",
"bytes": 67108864,
"sha256": "da4a4ba50191ed6fba8dda50c155c53e21c973ccf504f0a9aff0e2acffc48350"
},
"dtbo_a.img": {
"source": "/dev/mmcblk0p44",
"bytes": 8417280,
"sha256": "0e43a57d6423e8d24cd944fcad0939ed81160e847a6d4203e2f7c16a86d69c03"
},
"dtbo_b.img": {
"source": "/dev/mmcblk0p45",
"bytes": 8417280,
"sha256": "2453dbdb75d8e67e47ac531dc7a0b2cb36f00710b824e5d86019523f8d92cfe9"
},
"vbmeta_a.img": {
"source": "/dev/mmcblk0p63",
"bytes": 65536,
"sha256": "add7e48f84f3e98061f33aabb4cc678b6a631af66834f0fae579324b7acc681a"
},
"vbmeta_b.img": {
"source": "/dev/mmcblk0p64",
"bytes": 65536,
"sha256": "3e168134406526bc068e8f401231ce53b96f49ddf5a400a0a3ec25e118186cf2"
}
},
"serialSha256": "8c024ab1ad147c85b56ae800d741120291d1e5aaf97b81f6a935c0294845f6fe",
"activeSlot": "a",
"qualificationSha256": "e9ad84d2659114786b409eb27b6fba9c71bf6e5580252cd7297425500235face"
}

View file

@ -0,0 +1,14 @@
{
"schema": 1,
"device": "sargo",
"activeSlot": "a",
"serialSha256": "8c024ab1ad147c85b56ae800d741120291d1e5aaf97b81f6a935c0294845f6fe",
"kernelSha256": "e214e5e363bcf4db6387668ba723299e75009bc128fb0088f706cfb311db0080",
"private": true,
"flashReady": false,
"sourceBackupQualificationSha256": "8b45d37a992c2f338a678b1962edeb17b62822d8c05f4e72cf7cde6b1c831044",
"files": {
"backup/manifest.json": "620f0f6ae0a582d00c61c8d8bfb452eb52a0c251e54287a85ec298f757199170",
"layout.json": "b4ec8d4ea3d8d5c23f51b6bd514984feb1a9aacfef563dccda2c59b0d667fd2b"
}
}

View file

@ -0,0 +1,17 @@
{
"schema": 2,
"device": "/dev/mmcblk0p72",
"table": "direct",
"size": "53648801280",
"blkid": "/dev/mmcblk0p72: UUID=\"fed927a8-c694-4c9b-a8ec-48ad5a71139b\" TYPE=\"ext4\" PARTLABEL=\"userdata\" PARTUUID=\"dc37ac3d-ddc2-4350-a86d-5adaf76fcd78\"",
"partitionLabel": "userdata",
"filesystem": "ext4",
"storage": "raw-userdata-ext4",
"filesystemUuid": "fed927a8-c694-4c9b-a8ec-48ad5a71139b",
"backingDevice": "259:40",
"firstMiBSha256": "dc8f304baa35a09f82fdc4caa862b3dd71edfe05da34279c7a2d78cadb88cf4b",
"activeSlot": "a",
"serialSha256": "8c024ab1ad147c85b56ae800d741120291d1e5aaf97b81f6a935c0294845f6fe",
"observed": "2026-10-27T22:xxZ via temporary rescue; read-only block inspection",
"observedAt": "2026-10-27T22:xx:xxZ (temporary rescue; block reads only)"
}

View file

@ -0,0 +1,54 @@
// SPDX-License-Identifier: MPL-2.0
// Fixed-command local broker; camera access is confined to Android UID 1006.
#define _GNU_SOURCE
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/un.h>
#include <sys/wait.h>
#include <grp.h>
#include <signal.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define SOCKET_DIR "/data/local/vulpes-beatles/camera-ipc"
#define SOCKET_PATH SOCKET_DIR "/camera.sock"
#define WORKER "/data/local/tmp/vulpes-beatles-camera-worker"
static volatile sig_atomic_t stopping;
static void stop_now(int signal_number) { (void)signal_number; stopping = 1; }
int main(void) {
if (setpgid(0,0) && errno != EACCES) return 1;
struct sigaction action = {0}; action.sa_handler = stop_now;
sigemptyset(&action.sa_mask); sigaction(SIGTERM,&action,NULL); sigaction(SIGINT,&action,NULL);
umask(0);
if (mkdir(SOCKET_DIR,0777) && errno != EEXIST) return 2;
chmod(SOCKET_DIR,0777); unlink(SOCKET_PATH);
int server=socket(AF_UNIX,SOCK_STREAM,0); if(server<0) return 3;
struct sockaddr_un address={0}; address.sun_family=AF_UNIX;
strncpy(address.sun_path,SOCKET_PATH,sizeof(address.sun_path)-1);
if(bind(server,(struct sockaddr *)&address,sizeof(address)) || chmod(SOCKET_PATH,0666) || listen(server,2)) return 4;
printf("camera broker ready uid=%d socket=%s\n",getuid(),SOCKET_PATH); fflush(stdout);
while(!stopping) {
int client=accept(server,NULL,NULL);
if(client<0) { if(errno==EINTR) continue; break; }
pid_t child=fork();
if(child==0) {
close(server);
fprintf(stderr,"camera worker accepted pid=%d\n",getpid()); fflush(stderr);
gid_t groups[]={1006};
if(setgroups(1,groups) || setgid(1006) || setuid(1006)) _exit(120);
if(dup2(client,STDIN_FILENO)<0 || dup2(client,STDOUT_FILENO)<0) _exit(121);
if(client>STDOUT_FILENO) close(client);
setenv("LD_LIBRARY_PATH","/system/lib:/data/local/tmp",1);
execl(WORKER,"vulpes-beatles-camera-worker",(char *)NULL);
_exit(127);
}
close(client);
while(waitpid(-1,NULL,WNOHANG)>0) {}
}
close(server); unlink(SOCKET_PATH); return 0;
}

View file

@ -0,0 +1,175 @@
// SPDX-License-Identifier: MPL-2.0
// Bounded Android API 23 Camera1 diagnostic using an EGL/GLConsumer output path.
#include <camera/Camera.h>
#include <camera/CameraParameters.h>
#include <binder/ProcessState.h>
#include <gui/BufferQueue.h>
#include <gui/GLConsumer.h>
#include <gui/GraphicBufferAlloc.h>
#include <EGL/egl.h>
#include <GLES2/gl2.h>
#include <GLES2/gl2ext.h>
#include <utils/String16.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/stat.h>
#ifndef GL_TEXTURE_EXTERNAL_OES
#define GL_TEXTURE_EXTERNAL_OES 0x8D65
#endif
static GLuint compileShader(GLenum type, const char* source) {
GLuint shader = glCreateShader(type);
glShaderSource(shader, 1, &source, nullptr);
glCompileShader(shader);
GLint ok = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &ok);
if (!ok) {
char log[1024];
glGetShaderInfoLog(shader, sizeof(log), nullptr, log);
fprintf(stderr, "shader_error=%s\n", log);
return 0;
}
return shader;
}
int main() {
setvbuf(stderr, nullptr, _IONBF, 0);
android::ProcessState::self()->startThreadPool();
const int cameraCount = android::Camera::getNumberOfCameras();
fprintf(stderr, "camera_count=%d uid=%d\n", cameraCount, getuid());
if (cameraCount < 1) return 2;
android::sp<android::Camera> camera = android::Camera::connect(
0, android::String16("org.vulpes.camera.glprobe"),
android::Camera::USE_CALLING_UID);
if (camera == nullptr) {
fprintf(stderr, "camera_connect_failed\n");
return 3;
}
android::CameraParameters parameters(camera->getParameters());
int width = 0;
int height = 0;
parameters.getPreviewSize(&width, &height);
fprintf(stderr, "stock_preview_size=%dx%d format=%s\n", width, height,
parameters.getPreviewFormat());
if (width <= 0 || height <= 0 || width > 1920 || height > 1920) return 4;
EGLDisplay display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
EGLint major = 0, minor = 0;
if (display == EGL_NO_DISPLAY || !eglInitialize(display, &major, &minor)) {
fprintf(stderr, "egl_initialize_error=%x\n", eglGetError());
return 5;
}
const EGLint configAttributes[] = {
EGL_SURFACE_TYPE, EGL_PBUFFER_BIT,
EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,
EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_NONE};
EGLConfig config;
EGLint configCount = 0;
if (!eglChooseConfig(display, configAttributes, &config, 1, &configCount) ||
configCount < 1) return 6;
const EGLint surfaceAttributes[] = {
EGL_WIDTH, width, EGL_HEIGHT, height, EGL_NONE};
EGLSurface surface = eglCreatePbufferSurface(display, config, surfaceAttributes);
const EGLint contextAttributes[] = {EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE};
EGLContext context = eglCreateContext(display, config, EGL_NO_CONTEXT,
contextAttributes);
if (surface == EGL_NO_SURFACE || context == EGL_NO_CONTEXT ||
!eglMakeCurrent(display, surface, surface, context)) {
fprintf(stderr, "egl_context_error=%x\n", eglGetError());
return 7;
}
GLuint texture = 0;
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_EXTERNAL_OES, texture);
glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_EXTERNAL_OES, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
android::sp<android::IGraphicBufferProducer> producer;
android::sp<android::IGraphicBufferConsumer> consumer;
android::sp<android::GraphicBufferAlloc> allocator =
new android::GraphicBufferAlloc();
android::BufferQueue::createBufferQueue(&producer, &consumer, allocator);
consumer->setDefaultBufferSize(width, height);
android::sp<android::GLConsumer> glConsumer = new android::GLConsumer(
consumer, texture, GL_TEXTURE_EXTERNAL_OES, true, false);
const int targetStatus = camera->setPreviewTarget(producer);
fprintf(stderr, "set_preview_target=%d\n", targetStatus);
const int startStatus = camera->startPreview();
fprintf(stderr, "start_preview=%d\n", startStatus);
if (targetStatus || startStatus) return 8;
const char* vertexSource =
"attribute vec4 a; attribute vec2 t; varying vec2 v;"
"void main(){gl_Position=a;v=t;}";
const char* fragmentSource =
"#extension GL_OES_EGL_image_external : require\n"
"precision mediump float; uniform samplerExternalOES s; varying vec2 v;"
"void main(){gl_FragColor=texture2D(s,v);}";
GLuint program = glCreateProgram();
GLuint vertex = compileShader(GL_VERTEX_SHADER, vertexSource);
GLuint fragment = compileShader(GL_FRAGMENT_SHADER, fragmentSource);
if (!vertex || !fragment) return 9;
glAttachShader(program, vertex);
glAttachShader(program, fragment);
glBindAttribLocation(program, 0, "a");
glBindAttribLocation(program, 1, "t");
glLinkProgram(program);
GLint linked = 0;
glGetProgramiv(program, GL_LINK_STATUS, &linked);
if (!linked) {
char log[1024];
glGetProgramInfoLog(program, sizeof(log), nullptr, log);
fprintf(stderr, "program_link_error=%s\n", log);
return 10;
}
const GLfloat vertices[] = {-1, -1, 1, -1, -1, 1, 1, 1};
const GLfloat texcoords[] = {0, 1, 1, 1, 0, 0, 1, 0};
glUseProgram(program);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 0, vertices);
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 0, texcoords);
glEnableVertexAttribArray(1);
glUniform1i(glGetUniformLocation(program, "s"), 0);
bool captured = false;
for (int attempt = 0; attempt < 100 && !captured; ++attempt) {
usleep(100000);
const int updateStatus = glConsumer->updateTexImage();
if (updateStatus != 0) continue;
glViewport(0, 0, width, height);
glClear(GL_COLOR_BUFFER_BIT);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glFinish();
unsigned char* pixels = new unsigned char[width * height * 3];
glReadPixels(0, 0, width, height, GL_RGB, GL_UNSIGNED_BYTE, pixels);
const GLenum glError = glGetError();
if (glError == GL_NO_ERROR) {
FILE* output = fopen("/data/local/tmp/vulpes-camera-glconsumer.ppm", "wb");
if (output) {
fprintf(output, "P6\n%d %d\n255\n", width, height);
for (int y = height - 1; y >= 0; --y)
fwrite(pixels + y * width * 3, 3, width, output);
fclose(output);
chmod("/data/local/tmp/vulpes-camera-glconsumer.ppm", 0644);
captured = true;
}
}
delete[] pixels;
fprintf(stderr, "update=%d gl_error=%x captured=%d\n",
updateStatus, glError, captured);
}
fprintf(stderr, "stopping_preview\n");
camera->stopPreview();
fprintf(stderr, "disconnecting_camera\n");
camera->disconnect();
fprintf(stderr, "captured=%d\n", captured);
eglMakeCurrent(display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
eglDestroyContext(display, context);
eglDestroySurface(display, surface);
eglTerminate(display);
return captured ? 0 : 11;
}

View file

@ -0,0 +1,213 @@
// SPDX-License-Identifier: MPL-2.0
// RAM-only diagnostic reproducing Gecko's Android 6 GonkBufferQueue path.
#include <camera/Camera.h>
#include <camera/CameraParameters.h>
#include <system/camera.h>
#include <binder/ProcessState.h>
#include <gui/GraphicBufferAlloc.h>
#include <gui/IConsumerListener.h>
#include <ui/Fence.h>
#include <ui/GraphicBuffer.h>
#include <utils/String16.h>
#include <utils/Mutex.h>
#include <utils/Vector.h>
#include <stdio.h>
#include <unistd.h>
#include <inttypes.h>
#include <utils/Timers.h>
#include <sys/stat.h>
#define ANDROID_VERSION 23
#include "GonkBufferQueueLL.h"
#include "GonkBufferQueueProducer.h"
class CameraEvents : public android::CameraListener {
public:
void notify(int32_t type, int32_t ext1, int32_t ext2) override {
fprintf(stderr, "camera_notify=%d,%d,%d\n", type, ext1, ext2);
}
void postData(int32_t type, const android::sp<android::IMemory>& memory,
camera_frame_metadata_t*) override {
if (memory == nullptr) return;
const size_t size = memory->size();
const unsigned char* bytes = static_cast<const unsigned char*>(memory->pointer());
unsigned int low = 255, high = 0;
uint64_t sum = 0;
const size_t ySize = size < 640u * 480u ? size : 640u * 480u;
for (size_t i = 0; i < ySize; ++i) {
if (bytes[i] < low) low = bytes[i];
if (bytes[i] > high) high = bytes[i];
sum += bytes[i];
}
fprintf(stderr, "camera_data=%d size=%zu y=%u..%u mean=%.2f\n",
type, size, low, high, ySize ? (double)sum / ySize : 0);
static bool saved = false;
if (type == CAMERA_MSG_COMPRESSED_IMAGE) {
FILE* jpeg = fopen("/data/local/tmp/vulpes-camera-callback.jpg", "wb");
if (jpeg) { fwrite(bytes, 1, size, jpeg); fclose(jpeg); chmod("/data/local/tmp/vulpes-camera-callback.jpg", 0644); }
fprintf(stderr, "captured_jpeg=%zu\n", size);
} else if (!saved) {
FILE* output = fopen("/data/local/tmp/vulpes-camera-callback.yuv", "wb");
if (output) { fwrite(bytes, 1, size, output); fclose(output); chmod("/data/local/tmp/vulpes-camera-callback.yuv", 0644); saved = true; }
}
}
void postDataTimestamp(nsecs_t, int32_t type,
const android::sp<android::IMemory>&) override {
fprintf(stderr, "camera_timestamp_data=%d\n", type);
}
};
class QueueListener : public android::ConsumerListener {
public:
void onFrameAvailable(const android::BufferItem&) override {}
void onFrameReplaced(const android::BufferItem&) override {}
void onBuffersReleased() override {}
void onSidebandStreamChanged() override {}
};
int main() {
setvbuf(stderr, nullptr, _IONBF, 0);
android::ProcessState::self()->startThreadPool();
const int count = android::Camera::getNumberOfCameras();
fprintf(stderr, "camera_count=%d uid=%d\n", count, getuid());
if (count < 1) return 2;
android::sp<android::Camera> camera = android::Camera::connect(
// Match GonkCameraHwMgr.cpp exactly. CameraService/AppOps on this
// firmware treats the client package as part of camera authorization.
0, android::String16("gonk.camera"),
android::Camera::USE_CALLING_UID);
if (camera == nullptr) return 3;
android::CameraParameters params(camera->getParameters());
android::Vector<android::Size> previews, pictures;
params.getSupportedPreviewSizes(previews);
params.getSupportedPictureSizes(pictures);
android::Size picture = {0, 0};
for (size_t i = 0; i < pictures.size(); ++i)
if (pictures[i].width * pictures[i].height > picture.width * picture.height)
picture = pictures[i];
android::Size preview = {0, 0};
uint64_t bestDelta = UINT64_MAX;
for (size_t i = 0; i < previews.size(); ++i) {
const android::Size candidate = previews[i];
if (picture.width && candidate.width * picture.height != picture.width * candidate.height)
continue;
const uint64_t area = (uint64_t)candidate.width * candidate.height;
const uint64_t delta = area > 640u * 480u ? area - 640u * 480u : 640u * 480u - area;
if (delta < bestDelta) { preview = candidate; bestDelta = delta; }
}
if (!preview.width && previews.size()) preview = previews[0];
if (!preview.width || !picture.width) {
fprintf(stderr, "no supported preview/picture size (preview=%zu picture=%zu)\n",
previews.size(), pictures.size());
camera->disconnect();
return 4;
}
params.setPreviewSize(preview.width, preview.height);
params.setPictureSize(picture.width, picture.height);
// Gecko applies its recording hint after selecting still/preview sizes and
// before starting the preview. Keep the probe's Camera1 state in step.
params.set("recording-hint", "false");
android::String8 configured = params.flatten();
android::status_t status = camera->setParameters(configured);
fprintf(stderr, "set_parameters=%d requested_preview=%dx%d picture=%dx%d\n",
status, preview.width, preview.height, picture.width, picture.height);
if (status != android::NO_ERROR) { camera->disconnect(); return 4; }
params.unflatten(camera->getParameters());
int width = 0, height = 0;
params.getPreviewSize(&width, &height);
int pictureWidth = 0, pictureHeight = 0;
params.getPictureSize(&pictureWidth, &pictureHeight);
fprintf(stderr, "preview=%dx%d picture=%dx%d format=%s\n", width, height,
pictureWidth, pictureHeight, params.getPreviewFormat());
android::sp<android::IGraphicBufferProducer> producer;
android::sp<android::IGonkGraphicBufferConsumer> consumer;
android::sp<android::GraphicBufferAlloc> allocator =
new android::GraphicBufferAlloc();
android::GonkBufferQueue::createBufferQueue(&producer, &consumer, allocator);
status = static_cast<android::GonkBufferQueueProducer*>(producer.get())
->setSynchronousMode(false);
fprintf(stderr, "async_producer=%d\n", status);
status = consumer->setConsumerUsageBits(GRALLOC_USAGE_SW_READ_OFTEN);
fprintf(stderr, "cpu_read_usage=%d\n", status);
status = consumer->setMaxAcquiredBufferCount(4);
fprintf(stderr, "max_acquired=%d\n", status);
android::sp<QueueListener> listener = new QueueListener();
android::sp<android::IConsumerListener> proxy =
new android::GonkBufferQueue::ProxyConsumerListener(listener);
status = consumer->consumerConnect(proxy, false);
fprintf(stderr, "consumer_connect=%d\n", status);
android::sp<CameraEvents> cameraEvents = new CameraEvents();
camera->setListener(cameraEvents);
status = camera->setPreviewTarget(producer);
fprintf(stderr, "set_preview_target=%d\n", status);
camera->setPreviewCallbackFlags(CAMERA_FRAME_CALLBACK_FLAG_CAMERA);
fprintf(stderr, "preview_callback_flags=%d\n", CAMERA_FRAME_CALLBACK_FLAG_CAMERA);
const android::status_t focusEvents = camera->sendCommand(
CAMERA_CMD_ENABLE_FOCUS_MOVE_MSG, 1, 0);
fprintf(stderr, "focus_move_events=%d\n", focusEvents);
status = camera->startPreview();
fprintf(stderr, "start_preview=%d\n", status);
if (status) return 4;
unsigned acquired = 0;
bool saved = false;
const int64_t deadline = systemTime(SYSTEM_TIME_MONOTONIC) + 5000000000LL;
while (systemTime(SYSTEM_TIME_MONOTONIC) < deadline) {
android::IGonkGraphicBufferConsumer::BufferItem item;
status = consumer->acquireBuffer(&item, 0);
if (status != android::NO_ERROR) { usleep(20000); continue; }
++acquired;
android::sp<android::GraphicBuffer> buffer = item.mGraphicBuffer;
if (item.mFence != nullptr) item.mFence->waitForever("vulpes-gonk-camera");
void* pixels = nullptr;
if (buffer != nullptr &&
buffer->lock(GRALLOC_USAGE_SW_READ_OFTEN, &pixels) == android::NO_ERROR &&
pixels != nullptr) {
const size_t bytes = (size_t)buffer->getStride() * buffer->getHeight() * 3 / 2;
const unsigned char* y = static_cast<const unsigned char*>(pixels);
unsigned int minimum = 255, maximum = 0;
uint64_t sum = 0;
const size_t yBytes = (size_t)buffer->getStride() * buffer->getHeight();
for (size_t i = 0; i < yBytes; ++i) {
if (y[i] < minimum) minimum = y[i];
if (y[i] > maximum) maximum = y[i];
sum += y[i];
}
fprintf(stderr, "frame=%u number=%llu timestamp=%llu y=%u..%u mean=%.2f format=%d\n",
acquired, (unsigned long long)item.mFrameNumber, (unsigned long long)item.mTimestamp, minimum, maximum,
(double)sum / yBytes, buffer->format);
if (!saved || acquired % 10 == 0) {
FILE* output = fopen("/data/local/tmp/vulpes-camera-gonkqueue.nv21", "wb");
if (output) {
fwrite(pixels, 1, bytes, output);
fclose(output);
chmod("/data/local/tmp/vulpes-camera-gonkqueue.nv21", 0644);
FILE* meta = fopen("/data/local/tmp/vulpes-camera-gonkqueue.txt", "w");
if (meta) {
fprintf(meta, "%u %u %u %d %zu %u %u %u %u\n", buffer->getWidth(),
buffer->getHeight(), buffer->getStride(), buffer->format, bytes,
acquired, minimum, maximum, (unsigned)(sum / yBytes));
fclose(meta);
chmod("/data/local/tmp/vulpes-camera-gonkqueue.txt", 0644);
}
saved = true;
}
}
buffer->unlock();
}
consumer->releaseBuffer(item.mBuf, item.mFrameNumber, android::Fence::NO_FENCE);
}
fprintf(stderr, "acquired=%u saved=%d; taking_still\n", acquired, saved);
status = camera->takePicture(CAMERA_MSG_SHUTTER | CAMERA_MSG_COMPRESSED_IMAGE);
fprintf(stderr, "take_picture=%d\n", status);
sleep(3);
fprintf(stderr, "stopping_preview\n");
camera->stopPreview();
fprintf(stderr, "camera_stopped; disconnecting\n");
camera->disconnect();
consumer->consumerDisconnect();
fprintf(stderr, "done\n");
return saved ? 0 : 5;
}

View file

@ -0,0 +1,99 @@
// SPDX-License-Identifier: MPL-2.0
// Temporary, narrowly scoped API-23 permission controller for a RAM camera test.
#include <binder/Binder.h>
#include <binder/IServiceManager.h>
#include <binder/Parcel.h>
#include <binder/IPCThreadState.h>
#include <binder/ProcessState.h>
#include <camera/ICameraService.h>
#include <grp.h>
#include <utils/String16.h>
#include <utils/String8.h>
#include <utils/Vector.h>
#include <unistd.h>
#include <stdio.h>
namespace {
const android::String16 kDescriptor("android.os.IPermissionController");
const android::String16 kCameraPermission("android.permission.CAMERA");
const android::String16 kSystemEventPermission("android.permission.CAMERA_SEND_SYSTEM_EVENTS");
const android::String16 kProbePackage("gonk.camera");
const android::String16 kSystemPackage("android");
const int32_t kCameraUid = 1006;
class CameraPermissionBridge : public android::BBinder {
public:
const android::String16& getInterfaceDescriptor() const override {
return kDescriptor;
}
protected:
android::status_t onTransact(uint32_t code, const android::Parcel& data,
android::Parcel* reply, uint32_t flags) override {
if (code == android::IBinder::FIRST_CALL_TRANSACTION) {
if (!data.enforceInterface(kDescriptor)) return android::PERMISSION_DENIED;
const android::String16 permission = data.readString16();
const int32_t pid = data.readInt32();
const int32_t uid = data.readInt32();
(void)pid;
const bool grant = (permission == kCameraPermission && uid == kCameraUid) ||
(permission == kSystemEventPermission && uid == 1000);
android::String8 permissionName(permission);
fprintf(stderr, "permission check uid=%d name=%s grant=%d\n", uid,
permissionName.string(), grant);
reply->writeNoException();
reply->writeInt32(grant ? 1 : 0);
return android::NO_ERROR;
}
if (code == android::IBinder::FIRST_CALL_TRANSACTION + 1) {
if (!data.enforceInterface(kDescriptor)) return android::PERMISSION_DENIED;
const int32_t uid = data.readInt32();
reply->writeNoException();
const bool hasPackage = uid == kCameraUid || uid == 1000;
reply->writeInt32(hasPackage ? 1 : 0);
if (uid == kCameraUid) reply->writeString16(kProbePackage);
else if (uid == 1000) reply->writeString16(kSystemPackage);
return android::NO_ERROR;
}
if (code == android::IBinder::FIRST_CALL_TRANSACTION + 2) {
if (!data.enforceInterface(kDescriptor)) return android::PERMISSION_DENIED;
const android::String16 permission = data.readString16();
reply->writeNoException();
reply->writeInt32(permission == kCameraPermission ? 1 : 0);
return android::NO_ERROR;
}
return android::BBinder::onTransact(code, data, reply, flags);
}
};
}
int main(int argc, char**) {
if (argc != 1) return 2;
android::ProcessState::self()->startThreadPool();
android::sp<android::IServiceManager> manager = android::defaultServiceManager();
android::sp<CameraPermissionBridge> service = new CameraPermissionBridge();
android::status_t result = manager->addService(android::String16("permission"), service);
fprintf(stderr, "register permission bridge pid=%d uid=%d result=%d\n",
getpid(), getuid(), result);
if (result != android::NO_ERROR) return 3;
gid_t systemGroup = 1000;
if (setgroups(1, &systemGroup) || setgid(1000) || setuid(1000)) {
perror("drop permission bridge to Android system UID");
return 4;
}
fprintf(stderr, "permission bridge ready\n");
android::sp<android::IBinder> cameraBinder =
manager->checkService(android::String16("media.camera"));
android::sp<android::ICameraService> cameraService =
android::interface_cast<android::ICameraService>(cameraBinder);
if (cameraService != nullptr) {
const int32_t activeUsers[] = {0};
cameraService->notifySystemEvent(android::ICameraService::USER_SWITCHED,
activeUsers, 1);
fprintf(stderr, "notified CameraService active user 0\n");
} else {
fprintf(stderr, "CameraService unavailable for active-user notification\n");
}
android::IPCThreadState::self()->joinThreadPool();
return 0;
}

View file

@ -0,0 +1,173 @@
// SPDX-License-Identifier: MPL-2.0
// Bounded diagnostic for the Android Camera1 service on Nokia 2720 (API 23).
#include <camera/Camera.h>
#include <camera/CameraParameters.h>
#include <hardware/gralloc.h>
#include <binder/ProcessState.h>
#include <gui/BufferQueue.h>
#include <gui/CpuConsumer.h>
#include <gui/GraphicBufferAlloc.h>
#include <utils/String16.h>
#include <utils/String8.h>
#include <utils/Mutex.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/stat.h>
#include <thread>
class ProbeListener : public android::CameraListener {
public:
ProbeListener() : frames(0), bytes(0), jpegBytes(0), yMean(0), yMin(0), yMax(0) {}
void notify(int32_t type, int32_t first, int32_t second) override {
fprintf(stderr, "notify type=%d first=%d second=%d\n", type, first, second);
}
void postData(int32_t type, const android::sp<android::IMemory>& data,
camera_frame_metadata_t*) override {
if (data == nullptr) return;
if (type == CAMERA_MSG_PREVIEW_FRAME) {
android::Mutex::Autolock lock(mutex);
++frames;
bytes = data->size();
const size_t yBytes = bytes < 640u * 480u ? bytes : 640u * 480u;
const unsigned char* y = static_cast<const unsigned char*>(data->pointer());
uint64_t sum = 0;
yMin = 255;
yMax = 0;
for (size_t i = 0; i < yBytes; ++i) {
if (y[i] < yMin) yMin = y[i];
if (y[i] > yMax) yMax = y[i];
sum += y[i];
}
yMean = yBytes ? (double)sum / yBytes : 0;
if (frames == 20 && bytes) {
FILE* out = fopen("/data/local/tmp/vulpes-camera-frame20.nv21", "wb");
if (out) {
fwrite(data->pointer(), 1, bytes, out);
fclose(out);
chmod("/data/local/tmp/vulpes-camera-frame20.nv21", 0644);
}
}
} else if (type == CAMERA_MSG_COMPRESSED_IMAGE) {
FILE* out = fopen("/data/local/tmp/vulpes-camera-photo.jpg", "wb");
if (out) {
jpegBytes = fwrite(data->pointer(), 1, data->size(), out);
fclose(out);
chmod("/data/local/tmp/vulpes-camera-photo.jpg", 0644);
}
fprintf(stderr, "jpeg_callback_bytes=%zu\n", jpegBytes);
}
}
void postDataTimestamp(nsecs_t, int32_t,
const android::sp<android::IMemory>&) override {}
android::Mutex mutex;
unsigned frames;
size_t bytes;
size_t jpegBytes;
double yMean;
unsigned yMin;
unsigned yMax;
};
int main(int argc, char** argv) {
if (argc > 2) return 2;
setvbuf(stderr, nullptr, _IONBF, 0);
const int cameraId = argc == 2 ? atoi(argv[1]) : 0;
android::ProcessState::self()->startThreadPool();
const int count = android::Camera::getNumberOfCameras();
fprintf(stderr, "camera_count=%d uid=%d\n", count, getuid());
if (cameraId < 0 || cameraId >= count) return 3;
android::CameraInfo info;
int status = android::Camera::getCameraInfo(cameraId, &info);
fprintf(stderr, "camera_info_status=%d facing=%d orientation=%d\n",
status, info.facing, info.orientation);
android::sp<android::Camera> camera = android::Camera::connect(
cameraId, android::String16("gonk.camera"),
android::Camera::USE_CALLING_UID);
if (camera == nullptr) {
fprintf(stderr, "camera_connect_failed\n");
return 4;
}
android::CameraParameters configured(camera->getParameters());
// Stock KaiOS Camera reports app.camera.givenPreviewSize as 320x240
// on the beatles device. Gecko then selects this 4:3 size against the
// 1600x1200 still capture size. Keep the probe on that exact path.
configured.setPreviewSize(320, 240);
configured.setPictureSize(1600, 1200);
configured.setPreviewFormat("yuv420sp");
configured.set("recording-hint", "false");
status = camera->setParameters(configured.flatten());
fprintf(stderr, "set_full_parameters=%d\n", status);
android::sp<ProbeListener> listener = new ProbeListener();
camera->setListener(listener);
camera->setPreviewCallbackFlags(CAMERA_FRAME_CALLBACK_FLAG_ENABLE_MASK);
android::String8 parameters = camera->getParameters();
fprintf(stderr, "camera_parameters=%s\n", parameters.string());
fprintf(stderr, "before_bufferqueue_create\n");
android::sp<android::IGraphicBufferProducer> producer;
android::sp<android::IGraphicBufferConsumer> consumer;
android::sp<android::GraphicBufferAlloc> allocator = new android::GraphicBufferAlloc();
android::BufferQueue::createBufferQueue(&producer, &consumer, allocator);
fprintf(stderr, "after_bufferqueue_create producer=%d consumer=%d\n", producer != nullptr, consumer != nullptr);
fprintf(stderr, "before_cpuconsumer\n");
android::sp<android::CpuConsumer> previewConsumer =
new android::CpuConsumer(consumer, 4);
fprintf(stderr, "after_cpuconsumer\n");
status = consumer->setConsumerUsageBits(GRALLOC_USAGE_SW_READ_OFTEN |
GRALLOC_USAGE_HW_TEXTURE | GRALLOC_USAGE_HW_RENDER);
fprintf(stderr, "set_consumer_usage=%d bits=%08x\n", status,
(unsigned)(GRALLOC_USAGE_SW_READ_OFTEN | GRALLOC_USAGE_HW_TEXTURE | GRALLOC_USAGE_HW_RENDER));
fprintf(stderr, "before_consumer_setname\n");
previewConsumer->setName(android::String8("Vulpes Camera Probe"));
fprintf(stderr, "after_consumer_setname\n");
fprintf(stderr, "before_consumer_size\n");
previewConsumer->setDefaultBufferSize(320, 240);
fprintf(stderr, "after_consumer_size\n");
fprintf(stderr, "before_set_preview_target\n");
status = camera->setPreviewTarget(producer);
fprintf(stderr, "set_preview_target=%d\n", status);
status = camera->startPreview();
fprintf(stderr, "start_preview=%d\n", status);
unsigned graphicFrames = 0, graphicSamples = 0;
unsigned long long graphicSum = 0;
unsigned graphicMin = 255, graphicMax = 0;
for (int elapsed = 0; elapsed < 3; elapsed++) {
android::CpuConsumer::LockedBuffer buffer;
while (previewConsumer->lockNextBuffer(&buffer) == android::NO_ERROR) {
++graphicFrames;
fprintf(stderr, "graphic_frame=%u width=%u height=%u stride=%u format=%d\n",
graphicFrames, buffer.width, buffer.height, buffer.stride,
buffer.format);
if (buffer.data && graphicFrames >= 5) {
const size_t yBytes = (size_t)buffer.stride * buffer.height;
if (graphicFrames == 5) {
FILE* out = fopen("/data/local/tmp/vulpes-camera-graphic.nv21", "wb");
if (out) { fwrite(buffer.data, 1, yBytes * 3 / 2, out); fclose(out); chmod("/data/local/tmp/vulpes-camera-graphic.nv21", 0644); }
}
for (size_t i = 0; i < yBytes; i += 4096) {
unsigned value = buffer.data[i]; graphicSum += value; ++graphicSamples;
if (value < graphicMin) graphicMin = value;
if (value > graphicMax) graphicMax = value;
}
}
previewConsumer->unlockBuffer(buffer);
}
usleep(1000000);
}
{
android::Mutex::Autolock lock(listener->mutex);
fprintf(stderr, "preview_frames=%u frame_bytes=%zu callback_y_mean=%.2f min=%u max=%u graphic_frames=%u graphic_samples=%u graphic_y_mean=%.2f min=%u max=%u\n",
listener->frames, listener->bytes, listener->yMean, listener->yMin, listener->yMax,
graphicFrames, graphicSamples,
graphicSamples ? (double)graphicSum / graphicSamples : 0.0,
graphicMin, graphicMax);
}
std::thread([camera]() {
fprintf(stderr, "take_picture_begin\n");
const int pictureStatus = camera->takePicture(CAMERA_MSG_COMPRESSED_IMAGE);
fprintf(stderr, "take_picture=%d\n", pictureStatus);
}).detach();
sleep(4);
camera->stopPreview();
camera->disconnect();
return 0;
}

View file

@ -0,0 +1,214 @@
// SPDX-License-Identifier: MPL-2.0
// CameraService worker for the Nokia 2720 Flip. The parent broker drops this
// process to Android's camera UID before exec. Gaia talks to it over a local
// socket; it never receives Binder or elevated credentials.
#include <camera/Camera.h>
#include <camera/CameraParameters.h>
#include <binder/ProcessState.h>
#include <gui/BufferQueue.h>
#include <gui/CpuConsumer.h>
#include <gui/GraphicBufferAlloc.h>
#include <hardware/gralloc.h>
#include <utils/Timers.h>
#include <utils/Mutex.h>
#include <utils/String8.h>
#include <utils/String16.h>
#include <condition_variable>
#include <chrono>
#include <atomic>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include <unistd.h>
#include <sys/socket.h>
namespace {
bool write_all(int fd, const void *data, size_t size) {
const char *p = static_cast<const char *>(data);
while (size) {
ssize_t n = write(fd, p, size);
if (n <= 0) return false;
p += n; size -= static_cast<size_t>(n);
}
return true;
}
bool read_line(int fd, std::string *line) {
line->clear();
char c;
while (line->size() < 2048) {
ssize_t n = read(fd, &c, 1);
if (n <= 0) return false;
if (c == '\n') return true;
line->push_back(c);
}
return false;
}
bool send_json(int fd, const std::string &json, bool frame=false,
uint32_t sequence=0, const std::vector<uint8_t> *bytes=nullptr) {
uint32_t length = static_cast<uint32_t>(json.size());
if (frame) {
uint32_t header[2] = {sequence, bytes ? static_cast<uint32_t>(bytes->size()) : 0};
return write_all(fd, header, sizeof(header)) && (!bytes || write_all(fd,bytes->data(),bytes->size()));
}
return write_all(fd,&length,sizeof(length)) && write_all(fd,json.data(),json.size());
}
class CameraWorker : public android::CameraListener {
public:
explicit CameraWorker(int fd) : fd_(fd), camera_id_(0), sequence_(0), stopping_(false) {}
void notify(int32_t type, int32_t first, int32_t second) override {
fprintf(stderr,"camera notify type=%d first=%d second=%d\n",type,first,second);
}
void postData(int32_t type, const android::sp<android::IMemory> &data,
camera_frame_metadata_t *) override {
if (data == nullptr) return;
if (type == CAMERA_MSG_COMPRESSED_IMAGE) {
std::lock_guard<std::mutex> lock(mutex_);
jpeg_.assign(static_cast<const uint8_t *>(data->pointer()),
static_cast<const uint8_t *>(data->pointer()) + data->size());
ready_.notify_all();
return;
}
if (type != CAMERA_MSG_PREVIEW_FRAME) return;
const size_t expected = 320u * 240u * 3u / 2u;
if (data->size() < expected) return;
{
std::lock_guard<std::mutex> lock(mutex_);
frame_.assign(static_cast<const uint8_t *>(data->pointer()),
static_cast<const uint8_t *>(data->pointer()) + expected);
++sequence_;
if(sequence_==1) fprintf(stderr,"camera first frame bytes=%zu uid=%d\n",frame_.size(),getuid());
}
ready_.notify_all();
}
void postDataTimestamp(nsecs_t, int32_t,
const android::sp<android::IMemory> &) override {}
int run() {
android::ProcessState::self()->startThreadPool();
std::string line;
while (read_line(fd_, &line)) {
if (line == "start" || line == "start 0") {
if (!start()) { send_json(fd_,"{\"ready\":false,\"error\":\"CAMERA_START_FAILED\"}"); continue; }
if (!send_json(fd_,"{\"ready\":true,\"width\":320,\"height\":240,\"format\":\"nv21\",\"orientation\":270,\"flashModes\":[\"off\",\"auto\",\"on\",\"torch\"],\"pictureSizes\":[{\"width\":1600,\"height\":1200}],\"focusModes\":[\"fixed\"],\"maxFocusAreas\":0,\"zoomRatios\":[1] }")) return 1;
} else if (line == "frame") {
std::vector<uint8_t> frame;
uint32_t seq;
{
std::unique_lock<std::mutex> lock(mutex_);
const uint32_t previous = delivered_;
ready_.wait_for(lock,std::chrono::seconds(2),[&]{return sequence_>previous || stopping_;});
if (sequence_<=previous || frame_.empty()) { if(!send_json(fd_,"",true,sequence_,nullptr)) return 1; continue; }
seq=sequence_; delivered_=seq; frame=frame_;
}
if (!send_json(fd_,"",true,seq,&frame)) return 1;
} else if (line == "stop") {
stop(); send_json(fd_,"{\"ok\":true}"); return 0;
} else if (line.find("\"action\":\"flash\"") != std::string::npos) {
const size_t key=line.find("\"mode\":\"");
const size_t begin=key==std::string::npos?key:key+8;
const size_t end=begin==std::string::npos?begin:line.find('"',begin);
if(begin==std::string::npos || end==std::string::npos) {
send_json(fd_,"{\"ok\":false,\"error\":\"CAMERA_INVALID_FLASH_MODE\"}");continue;
}
const std::string mode=line.substr(begin,end-begin);
android::CameraParameters params(camera_->getParameters());
params.set(android::CameraParameters::KEY_FLASH_MODE,mode.c_str());
const android::status_t status=camera_->setParameters(params.flatten());
if(status==android::NO_ERROR) {
camera_->setPreviewCallbackFlags(CAMERA_FRAME_CALLBACK_FLAG_CAMERA);
camera_->startPreview();
send_json(fd_,"{\"ok\":true,\"result\":true}");
}
else send_json(fd_,"{\"ok\":false,\"error\":\"CAMERA_FLASH_UNAVAILABLE\"}");
} else if (line.find("\"action\":\"zoom\"") != std::string::npos) {
// This module reports a single zoom ratio (1x), so expose a stable
// no-op instead of asking Camera1 to select an unsupported index.
send_json(fd_,"{\"ok\":true,\"result\":{\"zoom\":1}}");
} else if (line.find("\"action\":\"picture\"") != std::string::npos) {
{ std::lock_guard<std::mutex> lock(mutex_); jpeg_.clear(); }
const android::status_t status=camera_->takePicture(CAMERA_MSG_COMPRESSED_IMAGE);
std::vector<uint8_t> jpeg;
{
std::unique_lock<std::mutex> lock(mutex_);
ready_.wait_for(lock,std::chrono::seconds(12),[&]{return !jpeg_.empty() || stopping_;});
jpeg=jpeg_;
}
camera_->setPreviewCallbackFlags(CAMERA_FRAME_CALLBACK_FLAG_CAMERA);
camera_->startPreview();
if(status!=android::NO_ERROR || jpeg.empty()) {
send_json(fd_,"{\"ok\":false,\"error\":\"CAMERA_CAPTURE_FAILED\"}");continue;
}
char reply[128];
std::snprintf(reply,sizeof(reply),"{\"ok\":true,\"jpegLength\":%zu}",jpeg.size());
if(!send_json(fd_,reply) || !write_all(fd_,jpeg.data(),jpeg.size())) return 1;
} else {
send_json(fd_,"{\"ok\":false,\"error\":\"CAMERA_UNSUPPORTED_COMMAND\"}");
}
}
stop(); return 0;
}
private:
bool start() {
if (camera_.get() != nullptr) stop();
fprintf(stderr,"camera count=%d uid=%d\n",android::Camera::getNumberOfCameras(),getuid());
camera_ = android::Camera::connect(camera_id_,android::String16("gonk.camera"),android::Camera::USE_CALLING_UID);
if (camera_.get() == nullptr) {fprintf(stderr,"camera connect failed\n");return false;}
android::CameraParameters params(camera_->getParameters());
params.setPreviewSize(320,240); params.setPictureSize(1600,1200);
params.setPreviewFormat("yuv420sp"); params.set("recording-hint","false");
android::status_t status=camera_->setParameters(params.flatten());
fprintf(stderr,"camera set parameters=%d\n",status);
if (status != android::NO_ERROR) { stop(); return false; }
android::sp<android::IGraphicBufferProducer> producer;
android::sp<android::IGraphicBufferConsumer> consumer;
android::sp<android::GraphicBufferAlloc> allocator = new android::GraphicBufferAlloc();
android::BufferQueue::createBufferQueue(&producer,&consumer,allocator);
consumer->setConsumerUsageBits(GRALLOC_USAGE_SW_READ_OFTEN | GRALLOC_USAGE_HW_TEXTURE | GRALLOC_USAGE_HW_RENDER);
consumer->setDefaultBufferSize(320,240);
consumer_ = new android::CpuConsumer(consumer,4);
consumer_->setName(android::String8("Vulpes Camera"));
status=camera_->setPreviewTarget(producer);
fprintf(stderr,"camera set target=%d\n",status);
if (status != android::NO_ERROR) { stop(); return false; }
camera_->setListener(this);
// The broker consumes copied Camera1 preview frames rather than video metadata.
camera_->setPreviewCallbackFlags(CAMERA_FRAME_CALLBACK_FLAG_CAMERA);
status=camera_->startPreview(); fprintf(stderr,"camera start preview=%d\n",status);
if (status != android::NO_ERROR) { stop(); return false; }
stopping_=false; delivered_=sequence_;
consumer_thread_=std::thread([this]{
while(!stopping_) {
android::CpuConsumer::LockedBuffer buffer;
if(consumer_->lockNextBuffer(&buffer)==android::NO_ERROR) consumer_->unlockBuffer(buffer);
else usleep(10000);
}
});
return true;
}
void stop() {
{ std::lock_guard<std::mutex> lock(mutex_); stopping_=true; }
ready_.notify_all();
if (camera_.get() != nullptr) { camera_->stopPreview(); camera_->disconnect(); camera_.clear(); }
if(consumer_thread_.joinable()) consumer_thread_.join();
consumer_.clear();
}
int fd_; int camera_id_;
android::sp<android::Camera> camera_;
android::sp<android::CpuConsumer> consumer_;
std::mutex mutex_; std::condition_variable ready_;
std::thread consumer_thread_;
std::vector<uint8_t> frame_, jpeg_; uint32_t sequence_, delivered_; std::atomic<bool> stopping_;
};
}
int main() {
setvbuf(stderr,nullptr,_IONBF,0);
android::sp<CameraWorker> worker = new CameraWorker(STDIN_FILENO);
return worker->run();
}

View file

@ -31,6 +31,7 @@
#include "beatles-lid.h"
static volatile sig_atomic_t interrupted;
static int phone_controls;
static int camera_broker_active;
static void interrupt(int sig) { interrupted = sig; }
static void inventory_camera_nodes(void) {
static const int minors[] = {0, 1, 32, 33};
@ -238,6 +239,11 @@ static void inside(int seconds) {
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");
if (camera_broker_active) {
if (mkdir(ROOT "/work/camera-ipc",0777) && errno!=EEXIST) _exit(1);
require(mount(BASE "/camera-ipc",ROOT "/work/camera-ipc",NULL,MS_BIND,NULL),"camera IPC mount");
require(chmod(ROOT "/work/camera-ipc",0777),"camera IPC permissions");
}
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);
@ -261,6 +267,7 @@ static void inside(int seconds) {
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("VULPES_TUNDRA_CAMERA",camera_broker_active?"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);
@ -272,6 +279,7 @@ int main(int argc, char **argv) {
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],"--temporary-240s") ? 240 :
(!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;
@ -309,6 +317,17 @@ int main(int argc, char **argv) {
* previous run's image be mistaken for a frame from this session. */
unlink(ROOT "/work/camera-frame.yuv");
unlink(ROOT "/work/camera-photo.jpg");
/* These names belong to our API-23 diagnostic; adb shell cannot remove
* camera-UID files from Android's sticky /data/local/tmp directory. */
unlink("/data/local/tmp/vulpes-camera-frame.nv21");
unlink("/data/local/tmp/vulpes-camera-frame20.nv21");
unlink("/data/local/tmp/vulpes-camera-graphic.nv21");
unlink("/data/local/tmp/vulpes-camera-photo.jpg");
unlink("/data/local/tmp/vulpes-camera-glconsumer.ppm");
unlink("/data/local/tmp/vulpes-camera-gonkqueue.nv21");
unlink("/data/local/tmp/vulpes-camera-gonkqueue.txt");
unlink("/data/local/tmp/vulpes-camera-callback.yuv");
unlink("/data/local/tmp/vulpes-camera-callback.jpg");
sleep(2);
if (!access(BASE "/camera-probe-once", F_OK)) {
unlink(BASE "/camera-probe-once");
@ -326,12 +345,80 @@ int main(int argc, char **argv) {
}
int result = android_service("/system/bin/stop");
printf("stop b2g: %d\n",result);
pid_t child = -1, radio = -1;
pid_t child = -1, radio = -1, permission_bridge = -1, camera_broker = -1;
struct lid_state lid;lid_init(&lid,fb);
if (!result) {
sleep(1);
puts("Camera node inventory after stopping B2G");
inventory_camera_nodes();
int gl_camera_probe = !access(BASE "/camera-gl-probe-once", F_OK);
int camera_service_probe = !access(BASE "/camera-service-probe-once", F_OK);
if (gl_camera_probe || camera_service_probe) {
unlink(BASE "/camera-service-probe-once");
unlink(BASE "/camera-gl-probe-once");
permission_bridge = fork();
if (!permission_bridge) {
int output = open(BASE "/camera-permission-bridge.log",
O_CREAT | O_TRUNC | O_WRONLY, 0644);
if (output >= 0) {
fchmod(output, 0644);
dup2(output, STDOUT_FILENO);
dup2(output, STDERR_FILENO);
close(output);
}
setenv("LD_LIBRARY_PATH", "/system/lib:/data/local/tmp", 1);
execl("/data/local/tmp/vulpes-camera-permission-bridge",
"vulpes-camera-permission-bridge", NULL);
_exit(127);
}
if (permission_bridge > 0) sleep(2);
pid_t probe = fork();
if (!probe) {
int output = open(gl_camera_probe ? BASE "/camera-gl-probe.log" :
BASE "/camera-service-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);
setenv("LD_LIBRARY_PATH", "/system/lib:/data/local/tmp", 1);
if (gl_camera_probe)
execl("/data/local/tmp/vulpes-camera-gl-probe-20261023",
"vulpes-camera-gl-probe-20261023", NULL);
else
execl("/data/local/tmp/vulpes-camera-probe-20261023",
"vulpes-camera-probe-20261023", "0", NULL);
_exit(127);
}
if (probe > 0) {
int probe_status = 0, finished = 0;
for (int attempt = 0; attempt < (gl_camera_probe ? 200 : 100); attempt++) {
if (waitpid(probe, &probe_status, WNOHANG) == probe) {
finished = 1;
break;
}
usleep(100000);
}
if (!finished) {
kill(probe, SIGKILL);
waitpid(probe, &probe_status, 0);
printf("Camera%s probe timed out after %d seconds\n",
gl_camera_probe ? " EGL/GLConsumer" : "1", gl_camera_probe ? 20 : 10);
} else {
printf("CameraService probe exit: %d\n",
WIFEXITED(probe_status) ? WEXITSTATUS(probe_status) : -1);
}
}
if (permission_bridge > 0) {
kill(permission_bridge, SIGTERM);
waitpid(permission_bridge, NULL, 0);
permission_bridge = -1;
}
}
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");
@ -393,6 +480,47 @@ int main(int argc, char **argv) {
if (prop > 0) waitpid(prop, NULL, 0);
if (!umount(dump_path)) rmdir(dump_path);
}
camera_broker_active = !access(BASE "/camera-broker-once",F_OK);
if (camera_broker_active) {
unlink(BASE "/camera-broker-once");
if (mkdir(BASE "/camera-ipc",0777) && errno!=EEXIST) perror("camera IPC directory");
chmod(BASE "/camera-ipc",0777);
unlink(BASE "/camera-ipc/camera.sock");
permission_bridge=fork();
if(!permission_bridge) {
int output=open(BASE "/camera-permission-bridge.log",O_CREAT|O_TRUNC|O_WRONLY,0644);
if(output>=0){fchmod(output,0644);dup2(output,STDOUT_FILENO);dup2(output,STDERR_FILENO);close(output);}
setenv("LD_LIBRARY_PATH","/system/lib:/data/local/tmp",1);
execl("/data/local/tmp/vulpes-camera-permission-bridge","vulpes-camera-permission-bridge",NULL);
_exit(127);
}
if(permission_bridge>0) {
int ready=0;
for(int i=0;i<50;i++) {
FILE *log=fopen(BASE "/camera-permission-bridge.log","r");
if(log){char line[256];while(fgets(line,sizeof(line),log))if(strstr(line,"permission bridge ready"))ready=1;fclose(log);}
if(ready)break;
usleep(100000);
}
}
camera_broker=fork();
if(!camera_broker) {
int output=open(BASE "/camera-broker.log",O_CREAT|O_TRUNC|O_WRONLY,0644);
if(output>=0){fchmod(output,0644);dup2(output,STDOUT_FILENO);dup2(output,STDERR_FILENO);close(output);}
setenv("LD_LIBRARY_PATH","/system/lib:/data/local/tmp",1);
execl("/data/local/tmp/vulpes-beatles-camera-broker","vulpes-beatles-camera-broker",NULL);
_exit(127);
}
if(camera_broker>0) {
int ready=0;
for(int i=0;i<50;i++) {
struct stat info;
if(!stat(BASE "/camera-ipc/camera.sock",&info) && S_ISSOCK(info.st_mode)){ready=1;break;}
usleep(100000);
}
if(!ready){fprintf(stderr,"Camera broker did not create its socket\n");camera_broker_active=0;}
}
}
if (seconds >= 180) {
radio = fork();
if (!radio) radio_inside();
@ -450,6 +578,18 @@ int main(int argc, char **argv) {
}
if(radio>0) {kill(-radio,SIGKILL);waitpid(radio,NULL,0);}
}
if (camera_broker > 0) {
kill(-camera_broker,SIGTERM);
for(int i=0;i<30;i++) {
if(waitpid(camera_broker,NULL,WNOHANG)==camera_broker){camera_broker=-1;break;}
usleep(100000);
}
if(camera_broker>0){kill(-camera_broker,SIGKILL);waitpid(camera_broker,NULL,0);}
}
if (permission_bridge > 0) {
kill(permission_bridge, SIGTERM);
waitpid(permission_bridge, NULL, 0);
}
clean_runtime();
lid_restore(&lid);
if (ioctl(fb,FBIOPUT_VSCREENINFO,&original)) perror("restore framebuffer mode");

View file

@ -0,0 +1,72 @@
#!/usr/bin/python3.11
# SPDX-License-Identifier: MPL-2.0
"""Narrow stdio-to-socket adapter for the Android CameraService broker."""
import os
import json
import socket
import sys
SOCKET = '/work/camera-ipc/camera.sock'
def read_exact(stream, size):
data = bytearray()
while len(data) < size:
chunk = stream.recv(size - len(data))
if not chunk:
raise EOFError('Camera broker closed the session')
data.extend(chunk)
return bytes(data)
def forward(sock, target, size):
header = read_exact(sock, size)
target.write(header); target.flush()
return header
def packet(sock, output):
header = read_exact(sock, 4)
size = int.from_bytes(header, 'little')
if size > 65536:
raise ValueError('Camera broker sent an oversized response')
payload = read_exact(sock, size)
output.write(header + payload); output.flush()
return json.loads(payload.decode('utf-8'))
def main():
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client.connect(SOCKET)
output = os.fdopen(os.dup(1), 'wb', buffering=0)
try:
client.sendall(b'start\n')
header = forward(client, output, 4)
length = int.from_bytes(header, 'little')
if not 0 < length <= 65536:
return 2
forward(client, output, length)
while True:
command = sys.stdin.buffer.readline(2049)
if not command or len(command) > 2048:
break
client.sendall(command)
if command.strip() == b'frame':
header = forward(client, output, 8)
length = int.from_bytes(header[4:], 'little')
if length > 320 * 240 * 3 // 2:
return 3
if length:
forward(client, output, length)
elif command.strip() == b'stop':
packet(client, output)
break
else:
response = packet(client, output)
jpeg = response.get('jpegLength', 0)
if isinstance(jpeg, int) and 0 < jpeg <= 32 * 1024 * 1024:
forward(client, output, jpeg)
except (BrokenPipeError, ConnectionError, EOFError):
return 0
finally:
client.close(); output.close()
return 0
if __name__ == '__main__':
sys.exit(main())

View file

@ -86,6 +86,7 @@ def control(p):
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))
set_session_idle(not enabled)
elif p.get('operation') == 'vibrate':
vibration.start(p.get('pattern'))
elif p.get('operation') == 'volume':
@ -103,6 +104,29 @@ def control(p):
raise ValueError('NOT_SUPPORTED')
return True
def set_session_idle(idle):
"""Tell logind that Gaia has switched the display between active and idle."""
try:
result = subprocess.run(['/usr/bin/loginctl', 'list-sessions', '--no-legend', '--no-pager'],
capture_output=True, text=True, timeout=3, check=True)
sessions = []
for line in result.stdout.splitlines():
fields = line.split()
if len(fields) >= 2 and fields[1] == '32011' and fields[0].isalnum():
sessions.append(fields[0])
if not sessions:
raise RuntimeError('No Tundra logind sessions found')
value = 'true' if idle else 'false'
for session in sessions:
subprocess.run(['/usr/bin/busctl', '--system', 'call', 'org.freedesktop.login1',
'/org/freedesktop/login1/session/'+session,
'org.freedesktop.login1.Session', 'SetIdleHint', 'b', value],
capture_output=True, text=True, timeout=3, check=True)
return True
except (OSError, RuntimeError, subprocess.SubprocessError) as error:
print('TUNDRA_LOGIND_IDLE_FAILED '+str(error), file=sys.stderr, flush=True)
return False
def serve():
SOCKET.parent.mkdir(parents=True, exist_ok=True)
SOCKET.unlink(missing_ok=True)

View file

@ -28,9 +28,8 @@ class Listener(C.Structure):
class Camera:
width, height = 640, 480
def __init__(self, index):
def __init__(self, index, width=640, height=480):
self.width, self.height = width, height
self.camera = None
self.display = self.surface = self.context = None
self.frame = None
@ -223,6 +222,8 @@ class Camera:
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--camera', type=int, choices=(0, 1), default=0)
parser.add_argument('--width', type=int, choices=(320, 640), default=640)
parser.add_argument('--height', type=int, choices=(240, 480), default=480)
parser.add_argument('--probe', action='store_true', help='Count frames for five seconds, retain no images')
args = parser.parse_args()
output = os.fdopen(os.dup(1), 'wb', buffering=0)
@ -235,7 +236,9 @@ def main():
os.dup2(2, 1) # HAL callbacks can print; keep stdout protocol uncontaminated.
camera = None
try:
camera = Camera(args.camera)
if (args.width, args.height) not in ((320,240),(640,480)):
raise ValueError('CAMERA_UNSUPPORTED_PREVIEW_SIZE')
camera = Camera(args.camera, args.width, args.height)
started = last_request = time.monotonic()
if not args.probe:
metadata = json.dumps(camera.metadata()).encode()

View file

@ -0,0 +1,32 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Enable the Sargo Qualcomm low-power states when the kernel exposes them."""
from pathlib import Path
import time
PARAMETER = Path('/sys/module/lpm_levels/parameters/sleep_disabled')
def main():
deadline = time.monotonic() + 8
while time.monotonic() < deadline and not PARAMETER.exists():
time.sleep(.1)
if not PARAMETER.exists():
print('Qualcomm lpm_levels parameter unavailable; leaving kernel defaults')
return
current = PARAMETER.read_text().strip()
if current == 'N':
print('Qualcomm low-power states are already enabled')
return
if current != 'Y':
print('Unknown sleep_disabled value; leaving kernel setting unchanged:', current)
return
PARAMETER.write_text('N')
if PARAMETER.read_text().strip() != 'N':
raise RuntimeError('Could not enable Qualcomm CPU low-power states')
print('Enabled Qualcomm CPU low-power states')
if __name__ == '__main__':
main()

View file

@ -15,7 +15,33 @@ def battery_state(root=Path('/sys/class/power_supply/battery')):
status = (root/'status').read_text().strip()
if not 0 <= level <= 100 or status not in ('Charging', 'Discharging', 'Full', 'Not charging'):
return None
return {'level': level/100, 'charging': status in ('Charging', 'Full'), 'status': status}
charging = status in ('Charging', 'Full')
state = {'level': level/100, 'charging': charging, 'status': status}
# Linux power-supply values use matching micro-units, so their ratio
# gives hours. Keep the estimate absent when the driver has no usable
# current/energy telemetry; Gaia will show that honestly.
try:
energy = int((root/'energy_now').read_text().strip())
power = abs(int((root/'power_now').read_text().strip()))
if charging:
energy = int((root/'energy_full').read_text().strip()) - energy
if power > 0 and energy > 0:
seconds = energy * 3600 / power
if 0 < seconds < 10 * 24 * 3600:
state['chargingTime' if charging else 'dischargingTime'] = seconds
except (OSError, ValueError):
try:
charge = int((root/'charge_counter').read_text().strip())
current = abs(int((root/'current_now').read_text().strip()))
if charging:
charge = int((root/'charge_full').read_text().strip()) - charge
if current > 0 and charge > 0:
seconds = charge * 3600 / current
if 0 < seconds < 10 * 24 * 3600:
state['chargingTime' if charging else 'dischargingTime'] = seconds
except (OSError, ValueError):
pass
return state
except (OSError, ValueError):
return None

View file

@ -65,6 +65,18 @@ Description=Checkpoint Tundra clock once per minute
OnBootSec=60
OnUnitActiveSec=60
""")
write('etc/systemd/system/tundra-cpuidle.service', '''[Unit]
Description=Enable Qualcomm CPU low-power states for Tundra
After=systemd-modules-load.service tundra-hal.service
Before=tundra-display.service
[Service]
Type=oneshot
ExecStartPre=/usr/bin/python3 /etc/tundra/wait-hal.py
ExecStart=/usr/bin/python3 /etc/tundra/enable-cpuidle.py
RemainAfterExit=yes
''')
shutil.copy2(Path(__file__).with_name('enable-cpuidle.py'),
root/'etc/tundra/enable-cpuidle.py')
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'):
@ -75,7 +87,7 @@ OnUnitActiveSec=60
write('etc/pam.d/other','auth required pam_deny.so\naccount required pam_deny.so\npassword required pam_deny.so\nsession required pam_deny.so\n')
write('etc/pam.d/common-account','account required pam_unix.so\n')
write('etc/pam.d/common-session-noninteractive','session required pam_unix.so\n')
write('etc/systemd/logind.conf','[Login]\nHandlePowerKey=ignore\nIdleAction=ignore\n')
write('etc/systemd/logind.conf','[Login]\nHandlePowerKey=ignore\nIdleAction=suspend\nIdleActionSec=30s\n')
write('etc/systemd/journald.conf','[Journal]\nStorage=volatile\nRuntimeMaxUse=32M\n')
write('etc/passwd','root:x:0:0:root:/root:/bin/bash\ndroidian:x:32011:32011:Vulpes trial:/home/droidian:/bin/bash\nsystem:x:1000:1000:Android HAL:/:/usr/sbin/nologin\n')
write('etc/group','root:x:0:\ndroidian:x:32011:\nsystem:x:1000:droidian\ngraphics:x:1003:droidian\nandroid_input:x:1004:droidian\nvideo:x:44:droidian\nrender:x:105:droidian\ninput:x:104:droidian\nnetdev:x:988:\n')
@ -111,8 +123,8 @@ TimeoutStopSec=15
write('etc/systemd/system/tundra-display.service','''[Unit]
Description=Gaia and Gecko on the Sargo display
Requires=tundra-hal.service dbus.service systemd-logind.service
After=tundra-hal.service dbus.service systemd-logind.service systemd-user-sessions.service
Wants=systemd-user-sessions.service
Wants=tundra-cpuidle.service
After=tundra-hal.service dbus.service systemd-logind.service tundra-cpuidle.service
[Service]
User=droidian
PAMName=tundra
@ -122,6 +134,7 @@ Environment=XDG_SESSION_TYPE=wayland
Environment=XDG_CURRENT_DESKTOP=Vulpes
Environment=LANG=C.UTF-8
Environment=VULPES_TUNDRA=1
Environment=VULPES_DEVICE=sargo
Environment=VULPES_TUNDRA_RADIO=1
Environment=WLR_BACKENDS=hwcomposer,libinput
Environment=EGL_PLATFORM=hwcomposer
@ -144,11 +157,15 @@ TimeoutStopSec=15
display.write_text(display.read_text().replace('--own-compositor --hybris-egl',
'--own-compositor --hybris-egl --diagnostics'))
write('etc/tundra/wait-hal.py','''import subprocess,time
for _ in range(120):
p=subprocess.run(['/usr/bin/lxc-attach','-n','tundra-hal','--','/system/bin/getprop','init.svc.vendor.hwcomposer-2-2'],capture_output=True,text=True)
deadline=time.monotonic()+8
while time.monotonic()<deadline:
try:
p=subprocess.run(['/usr/bin/lxc-attach','-n','tundra-hal','--','/system/bin/getprop','init.svc.vendor.hwcomposer-2-2'],capture_output=True,text=True,timeout=.5)
except subprocess.TimeoutExpired:
continue
if p.stdout.strip()=='running':break
time.sleep(.5)
else:raise RuntimeError('Display HAL did not start')
time.sleep(.25)
else:raise RuntimeError('Display HAL did not start within 8 seconds')
''')
# Cellular HAL and oFono must see the same rmnet interfaces. Display-only
# diagnostics retain a separate network namespace. No Android network manager runs.
@ -161,7 +178,6 @@ lxc.apparmor.profile = unconfined
lxc.autodev = 0
lxc.cap.drop = mac_admin mac_override sys_boot
lxc.cgroup.devices.deny = b *:* rwm
lxc.cgroup2.devices.deny = b *:* rwm
lxc.cgroup2.memory.max = 536870912
lxc.mount.entry = tmpfs dev tmpfs nosuid 0 0
lxc.mount.entry = /dev/__properties__ dev/__properties__ none bind,create=dir 0 0
@ -173,6 +189,8 @@ lxc.mount.entry = /mnt mnt none rbind 0 0
''')
for name in ('hal_policy.py','setup-hal.py','lxc-guard.py','hardware-status.py','prepare-network.py','wifi-control.py','radio-control.py','mobile-settings.py','sms-inbox.py','call-audio.py','camera-preview.py','buttons.py'):
shutil.copy2(Path(__file__).parent/name,root/'etc/tundra'/name)
shutil.copy2(Path(__file__).resolve().parents[1]/'tools/inspect-hardware.py',
root/'etc/tundra/inspect-hardware.py')
write('etc/systemd/system/tundra-hardware.service', '''[Unit]
Description=Tundra read-only battery and network state
After=systemd-udev-trigger.service

View file

@ -175,6 +175,39 @@ def active_wifi_uuid():
except ValueError:return None
def device_state():
"""Return NetworkManager's live link state, even when its AP vanished."""
result = nm('-g', 'GENERAL.STATE', 'device', 'show', DEVICE, timeout=5)
if result.returncode:
raise WifiError('WIFI_UNAVAILABLE')
match = re.match(r'\s*(\d+)', result.stdout)
if not match:
raise WifiError('WIFI_UNAVAILABLE')
code = int(match.group(1))
state = {20: 'unavailable', 30: 'disconnected', 40: 'connecting',
50: 'connecting', 60: 'connecting', 70: 'connecting',
80: 'connecting', 90: 'connecting', 100: 'connected',
110: 'disconnecting', 120: 'failed'}.get(code, 'unknown')
return state
def current_network(profile_dir, observed=()):
"""Resolve the active profile to a remembered SSID if scan results omit it."""
if device_state() != 'connected':
return None
uid = active_wifi_uuid()
if not uid or uid == HOTSPOT_UUID:
return None
for network in observed:
if network.get('connected'):
return network
saved = next((row for row in saved_profiles(profile_dir) if row['id'] == uid), None)
if not saved:
return None
return {'ssid': saved['ssid'], 'security': saved['security'],
'relSignalStrength': 0, 'savedId': uid, 'connected': True}
def hotspot_state(profile_dir):
path=profile_dir/'vulpes-hotspot.nmconnection'
result={'enabled':active_wifi_uuid()==HOTSPOT_UUID,'configured':False,'ssid':'Vulpes OS','security':'wpa2-psk','password':''}
@ -269,11 +302,14 @@ def dispatch(request, profile_dir=PROFILE_DIR):
for network in found:
stored = next((row for row in saved if row['ssid'] == network['ssid'] and row['security'] == network['security']), None)
network.update(known=stored is not None, savedId=stored['id'] if stored else None)
state = device_state() if enabled else 'disabled'
active = current_network(profile_dir, found) if state == 'connected' else None
info={}
if any(n['connected'] for n in found):
if active:
try:info=connection_info()
except (WifiError,OSError,subprocess.TimeoutExpired):pass
return {'enabled': enabled, 'networks': found, 'connectionInformation':info}
return {'enabled': enabled, 'state':state, 'network':active,
'networks': found, 'connectionInformation':info}
if operation == 'disconnect':
if nm('device', 'disconnect', DEVICE).returncode:
raise WifiError('WIFI_DISCONNECT_FAILED')
@ -304,7 +340,10 @@ def dispatch(request, profile_dir=PROFILE_DIR):
private_write(target, text)
if nm('connection', 'load', str(target)).returncode:
raise WifiError('WIFI_UNAVAILABLE')
if nm('connection', 'up', 'uuid', uid).returncode:
# NetworkManager may retain a failed activation after the AP goes out
# of range. Explicitly drop that stale activation before reconnecting.
nm('connection', 'down', 'uuid', uid)
if nm('connection', 'up', 'uuid', uid, 'ifname', DEVICE).returncode:
raise WifiError('WIFI_CONNECT_FAILED')
connected = True
return {'connected': True}

View file

@ -20,6 +20,15 @@ class Buttons(unittest.TestCase):
for p in [{'operation':'screen','enabled':True,'level':-1},{'operation':'volume','level':float('nan')},{'operation':'exec','command':'true'}]:
with self.assertRaises(ValueError):b.control(p)
def test_display_state_updates_all_logind_sessions(self):
sessions='1 32011 droidian - 1017 manager - no -\nc2 32011 droidian - 1082 background - no -\nc3 32011 droidian seat0 1119 user tty7 no -\n'
with patch.object(b.subprocess,'run') as run:
run.side_effect=[type('Result',(),{'stdout':sessions})(),None,None,None]
self.assertTrue(b.set_session_idle(True))
calls=[call.args[0] for call in run.call_args_list[1:]]
self.assertEqual(len(calls),3)
self.assertTrue(all(call[-2:]==['b','true'] for call in calls))
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]')

View file

@ -113,11 +113,12 @@ on boot
self.assertIn(line.split(':')[-1], shells)
config = (root/'etc/tundra/lxc.conf').read_text()
self.assertIn('lxc.cgroup.devices.deny = b *:* rwm', config)
self.assertIn('lxc.cgroup2.devices.deny = b *:* rwm', config)
self.assertNotIn('lxc.cgroup2.devices.deny', config)
self.assertNotIn('host', config)
self.assertIn('lxc.cgroup2.memory.max = 536870912', config)
service=(root/'etc/systemd/system/tundra-display.service').read_text()
self.assertIn('ExecStartPre=+chvt 7', service)
self.assertIn('Environment=VULPES_DEVICE=sargo', service)
self.assertNotIn('ExecStartPost=', service)
# systemd-user's PAM stack includes these on the reference root.
for name in ('common-account', 'common-session-noninteractive'):

View file

@ -38,6 +38,40 @@ class WifiTests(unittest.TestCase):
self.assertEqual(wifi.connection_info(),{})
run.assert_not_called()
def test_status_reports_live_disconnection_instead_of_stale_connected_state(self):
def nm(*args, **kwargs):
if args[:2] == ('radio', 'wifi'):
return SimpleNamespace(returncode=0, stdout='enabled\n')
if args[:2] == ('-g', 'GENERAL.STATE'):
return SimpleNamespace(returncode=0, stdout='30 (disconnected)\n')
raise AssertionError(args)
with tempfile.TemporaryDirectory() as folder, patch.object(wifi, 'nm', side_effect=nm):
result = wifi.dispatch({'operation':'status'}, Path(folder))
self.assertEqual(result['state'], 'disconnected')
self.assertIsNone(result['network'])
def test_status_recovers_active_saved_network_even_if_scan_has_no_ap_row(self):
with tempfile.TemporaryDirectory() as folder:
profile_dir = Path(folder)
uid = 'd31f8c9d-a1f9-4df1-876a-491fe814cf2a'
profile_dir.mkdir(exist_ok=True)
(profile_dir / ('vulpes-' + uid + '.nmconnection')).write_text(
wifi.profile_text(AP, 'testpassword', uid))
def nm(*args, **kwargs):
if args[:2] == ('radio', 'wifi'):
return SimpleNamespace(returncode=0, stdout='enabled\n')
if args[:2] == ('-g', 'GENERAL.STATE'):
return SimpleNamespace(returncode=0, stdout='100 (connected)\n')
if args[:2] == ('-g', 'GENERAL.CON-UUID'):
return SimpleNamespace(returncode=0, stdout=uid+'\n')
raise AssertionError(args)
with patch.object(wifi, 'nm', side_effect=nm), \
patch.object(wifi, 'connection_info', return_value={'ipAddress':'192.0.2.2'}):
result = wifi.dispatch({'operation':'status'}, profile_dir)
self.assertEqual(result['state'], 'connected')
self.assertEqual(result['network']['ssid'], AP['ssid'])
self.assertEqual(result['network']['savedId'], uid)
def test_secret_persists_only_after_success(self):
for fail in (False, True):
with tempfile.TemporaryDirectory() as temporary:

View file

@ -0,0 +1,49 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Low-overhead, read-only battery and thermal sampler for a RAM trial."""
import json
from pathlib import Path
import time
def values(directory, names):
result = {}
for name in names:
try:
result[name] = (directory / name).read_text().strip()
except OSError:
pass
return result
def sample():
supplies = {}
for path in sorted(Path('/sys/class/power_supply').iterdir()):
supplies[path.name] = values(path, (
'type', 'status', 'online', 'capacity', 'current_now',
'voltage_now', 'power_now', 'charge_now', 'energy_now', 'temp'))
thermal = {}
for path in sorted(Path('/sys/class/thermal').glob('thermal_zone*')):
item = values(path, ('type', 'temp'))
if item:
thermal[path.name] = item
idle = {}
for path in sorted(Path('/sys/devices/system/cpu').glob('cpu[0-9]*/cpuidle/state*')):
item = values(path, ('name', 'time', 'usage', 'disable'))
if item:
idle[str(path.relative_to('/sys/devices/system/cpu'))] = item
return {'timeUnix': time.time(), 'timeMonotonic': time.monotonic(),
'powerSupplies': supplies, 'thermalZones': thermal, 'cpuIdle': idle}
def main():
output = Path('/run/tundra/battery-idle.jsonl')
output.parent.mkdir(parents=True, exist_ok=True)
with output.open('a', buffering=1) as stream:
while True:
stream.write(json.dumps(sample(), separators=(',', ':')) + '\n')
time.sleep(30)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,64 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Build the Nokia CameraService worker and its fixed-command broker."""
import hashlib
import json
from pathlib import Path
import subprocess
ROOT = Path(__file__).resolve().parents[2]
ENGINE = ROOT / 'tundra/out/beatles/gecko-156.0.1'
BUILD = ENGINE / 'camera-broker-build'
NDK = BUILD / 'android-ndk-r27d'
HEADERS = BUILD / 'android-headers-api23/android-headers-23/23'
AOSP = BUILD / 'aosp/include'
LIBS = ENGINE / 'camera-probe-runtime'
OUT = BUILD
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def build(name, source, command):
target = OUT / name
stamp = target.with_suffix('.build.json')
record = {'source': digest(source), 'command': command}
if target.is_file() and stamp.is_file():
try:
if json.loads(stamp.read_text()) == record:
print('Using cached camera tool:', name)
return target
except (OSError, ValueError):
pass
subprocess.run(command, check=True)
target.chmod(0o755)
stamp.write_text(json.dumps(record, sort_keys=True) + '\n')
return target
def main():
required = [NDK / 'toolchains/llvm/prebuilt/linux-x86_64/bin/clang++',
NDK / 'toolchains/llvm/prebuilt/linux-x86_64/bin/clang',
HEADERS / 'linux/binder.h', AOSP / 'camera/Camera.h',
LIBS / 'libc++_shared.so', LIBS / 'beatles-camera-permission-bridge']
missing = [str(path) for path in required if not path.exists()]
if missing:
raise SystemExit('Private API-23 Camera1 build inputs missing:\n ' + '\n '.join(missing))
sysroot = NDK / 'toolchains/llvm/prebuilt/linux-x86_64/sysroot'
cxx = str(NDK / 'toolchains/llvm/prebuilt/linux-x86_64/bin/clang++')
cc = str(NDK / 'toolchains/llvm/prebuilt/linux-x86_64/bin/clang')
common = ['--target=armv7a-linux-androideabi23', '--sysroot=' + str(sysroot)]
worker_source = ROOT / 'tundra/probes/beatles-camera-worker.cpp'
worker_command = [cxx, *common, '-fno-rtti', '-I', str(HEADERS), '-I', str(AOSP),
str(worker_source), '-L', str(LIBS), '-Wl,-rpath,/data/local/tmp',
'-Wl,--allow-shlib-undefined', '-lcamera_client', '-lbinder', '-lgui',
'-lutils', '-lcutils', '-llog', '-lui', '-lhardware', '-lmedia',
'-lcamera_metadata', '-lsync', '-lbase', '-lc++_shared', '-o']
build('beatles-camera-worker', worker_source,
worker_command + [str(OUT / 'beatles-camera-worker')])
broker_source = ROOT / 'tundra/probes/beatles-camera-broker.c'
broker_command = [cc, *common, '-Wall', '-Wextra', '-Werror', str(broker_source), '-o']
build('beatles-camera-broker', broker_source,
broker_command + [str(OUT / 'beatles-camera-broker')])
print('Android Camera1 worker and broker are ready.')
if __name__ == '__main__':
main()

View file

@ -18,6 +18,7 @@ from cryptography.hazmat.primitives import serialization
from ramdisk_reference import read_newc, select_helpers
from storage_layout import mount_table
from shared_ui import compress_archive
from kernel_cmdline import vulpes_cmdline
from tundra import ROOT, cpio, checked_source, digest, save, linker
@ -33,7 +34,9 @@ def main():
parser.add_argument('--layout',type=Path,required=True)
parser.add_argument('--device-workspace', type=Path,
help='Private per-phone workspace; never use the development inventory')
parser.add_argument('--native',action='store_true',help='Switch to the staged native root with a ten-minute watchdog')
parser.add_argument('--native',action='store_true',help='Switch to the staged native root with a timed watchdog')
parser.add_argument('--watchdog-seconds', type=int, default=600,
help='RAM trial auto-reboot deadline (60–3600 seconds; default: 600)')
parser.add_argument('--system-image',type=Path,help='system.json for a self-contained image staged at /tundra/images/<sha256>.ext4')
parser.add_argument('--differential-ui', action='store_true', help='Bundle only UI files differing from the verified immutable system image')
parser.add_argument('--hold-hal',action='store_true',help='Boot basic.target first so HAL startup can be traced over USB')
@ -45,6 +48,8 @@ def main():
parser.add_argument('--state', type=Path, help='Prepared Tundra data-image manifest; enables persistent writes in that file')
parser.add_argument('--no-deadline', action='store_true', help='Keep the persistent development boot running; requires --state')
args=parser.parse_args()
if not 60 <= args.watchdog_seconds <= 3600:
parser.error('--watchdog-seconds must be between 60 and 3600')
if args.hold_hal and not args.native:parser.error('--hold-hal requires --native')
if args.system_image and not args.native:parser.error('--system-image requires --native')
if args.differential_ui and not args.system_image:parser.error('--differential-ui requires --system-image')
@ -79,6 +84,9 @@ def main():
else:
plan,_=load_boot().checked_plan()
storage_table = mount_table(layout)
if state and storage_table is None and not (
layout.get('schema') == 2 and layout.get('storage') == 'raw-userdata-ext4'):
raise ValueError('Persistent trials require inventoried ext4 userdata')
out=args.output.resolve();out.mkdir(parents=True,exist_ok=False)
source=Path(plan['backupDirectory'])/'boot_a.img'
packager=checked_source('aosp-mkbootimg')
@ -86,6 +94,8 @@ def main():
result=subprocess.check_output(['python3',str(packager/'unpack_bootimg.py'),'--boot_img',str(source),'--out',str(unpack),'--format=mkbootimg'],text=True)
options=shlex.split(result);fields=dict(zip(options[::2],options[1::2]))
if fields['--header_version']!='0':raise ValueError('Expected header v0')
source_cmdline=fields['--cmdline']
fields['--cmdline'],removed_kernel_parameters=vulpes_cmdline(source_cmdline)
with tempfile.TemporaryDirectory() as temp:
helpers=select_helpers(read_newc((unpack/'ramdisk').read_bytes()),Path(temp),
extra=('sbin/blkid','sbin/e2fsck') if state else ())
@ -113,15 +123,23 @@ def main():
(out/'known_hosts').write_text('[10.15.19.82]:2222 '+hostpublic+'\n[127.0.0.1]:2222 '+hostpublic+'\n')
init=(ROOT/'boot/rescue-init.sh').read_bytes().replace(b'# No LVM activation',
('EXPECTED_STORAGE_HASH='+shlex.quote(layout['firstMiBSha256'])+'\n# No LVM activation').encode())
init=init.replace(b"'0 104448000 linear /dev/mmcblk0p72 329728'",
shlex.quote(storage_table).encode())
sources=[Path(__file__),ROOT/'boot/rescue-init.sh',ROOT/'tools/ramdisk_reference.py',
if storage_table is None:
uuid_hex=layout['filesystemUuid'].replace('-','')
init=init.replace(b'# No LVM activation',
('RAW_USERDATA_EXT4=1\nEXPECTED_STORAGE_UUID_HEX='+uuid_hex+
'\n# No LVM activation').encode())
else:
init=init.replace(b"'0 104448000 linear /dev/mmcblk0p72 329728'",
shlex.quote(storage_table).encode())
sources=[Path(__file__),ROOT/'boot/rescue-init.sh',ROOT/'tools/kernel_cmdline.py',ROOT/'tools/ramdisk_reference.py',
ROOT/'tools/storage_layout.py']
if args.device_workspace:
sources += [ROOT/'tools/sargo_workspace.py', ROOT/'tools/boot_backup.py']
if state:
# Keep the existing layout intact; only /tundra/data/<UUID>.ext4 is used
# for Tundra state. Opening its containing filesystem RW is necessary.
if storage_table is None:
init=init.replace(b'RAW_USERDATA_EXT4=1',b'RAW_USERDATA_EXT4=1\nTUNDRA_USERDATA_RW=1')
init=init.replace(b'--readonly --table', b'--table')
init=init.replace(b'-o ro,noload /dev/mapper/tundra-installed-ro', b'-o rw /dev/mapper/tundra-installed-ro')
init=init.replace(b'# Temporary RAM-only boot. Installed data is never mounted writable.', b'# Private persistent boot. Only the dedicated Tundra data file receives system writes.')
@ -130,7 +148,7 @@ def main():
shared_ui = {}
if args.native:
binary=out/'watchdog'
subprocess.run(['clang','--target=aarch64-linux-gnu','--ld-path='+str(linker()),'-Os','-ffreestanding','-fno-builtin','-fno-stack-protector','-fno-pie','-fno-unwind-tables','-fno-asynchronous-unwind-tables','-Wall','-Wextra','-Werror','-nostdlib','-static','-Wl,--build-id=none','-Wl,-e,_start',str(ROOT/'boot/watchdog.c'),'-o',str(binary)],check=True)
subprocess.run(['clang','--target=aarch64-linux-gnu','--ld-path='+str(linker()),'-Os','-ffreestanding','-fno-builtin','-fno-stack-protector','-fno-pie','-fno-unwind-tables','-fno-asynchronous-unwind-tables','-Wall','-Wextra','-Werror','-nostdlib','-static','-Wl,--build-id=none','-Wl,-e,_start','-DTUNDRA_WATCHDOG_SECONDS='+str(args.watchdog_seconds),str(ROOT/'boot/watchdog.c'),'-o',str(binary)],check=True)
init=init.replace(b"(sleep 480; echo 'Tundra trial deadline reached'; reboot -f) &",b'/sbin/tundra-watchdog &')
if args.no_deadline:
init=init.replace(b'/sbin/tundra-watchdog &', b'# Persistent development boot: no timed reboot.')
@ -174,6 +192,8 @@ def main():
archive.add(config_dir, arcname=directory, filter=owned)
files['native-config.tar']=(0o600,compress_archive(buffer.getvalue()))
sources += list(sorted((ROOT/'runtime').glob('*.py')))
if args.runtime_config:
sources.append(ROOT/'tools/inspect-hardware.py')
files['sbin/tundra-watchdog']=(0o755,binary.read_bytes())
native_init=(ROOT/'boot/native-root.sh').read_bytes().replace(b'# BUILD_TIME_FLOOR', ('BUILD_EPOCH='+str(built_at)).encode())
if args.runtime_config:
@ -181,7 +201,8 @@ def main():
if system:
sha = system['imageSha256']
native_init = native_init.replace(b'# A kernel fallback',
('SYSTEM_IMAGE=/installed/tundra/images/'+sha+'.ext4\nSYSTEM_IMAGE_SHA='+sha+'\n# A kernel fallback').encode())
('SYSTEM_IMAGE=/installed/tundra/images/'+sha+'.ext4\nSYSTEM_IMAGE_SHA='+sha+
'\nSYSTEM_IMAGE_BYTES='+str(system['bytes'])+'\n# A kernel fallback').encode())
# Bundle UI changes with the boot trial rather than retransferring
# the entire immutable system image for each CSS/JS adjustment.
from shared_ui import build_payload, differential_payload
@ -240,9 +261,10 @@ def main():
shutil.copy2(path,target)
save(out/'build.json',{'scope':'Private persistent Sargo development boot; unqualified' if state else ('Private temporary native-root trial; unqualified' if args.native else 'Private temporary SSH boot; installed root read-only; no graphical session'),
'builtAtUnix':built_at,'runtimeConfigOverlay':args.runtime_config,'networkTrial':args.network,'hardwareTrial':args.hardware,'interactiveDiagnostics':args.diagnostics,'image':image.name,'imageSha256':digest(image),'sourceBootSha256':digest(source),
'removedKernelParameters':removed_kernel_parameters,
'serialSha256':plan['serialSha256'],'kernelSha256':kernel_hash,
'deviceWorkspaceSha256':plan.get('workspaceSha256'),
'ramdiskSha256':digest(ramdisk),'roundtripPassed':True,'flashReady':False,'localDebugger':args.local_debugger,'watchdogSeconds':None if args.no_deadline else (600 if args.native else 480),'persistentState':state,'storageWrites':bool(state),'nativeRootRequested':args.native,'halHeldForDiagnostics':args.hold_hal,
'ramdiskSha256':digest(ramdisk),'roundtripPassed':True,'flashReady':False,'localDebugger':args.local_debugger,'watchdogSeconds':None if args.no_deadline else (args.watchdog_seconds if args.native else 480),'persistentState':state,'storageWrites':bool(state),'nativeRootRequested':args.native,'halHeldForDiagnostics':args.hold_hal,
'layout':layout,'layoutSha256':digest(args.layout),'systemImage':system,'sharedUiHashes':shared_ui,'sourceSnapshot':True,'sources':{str(p.relative_to(ROOT)):digest(p) for p in sources},
'helpers':{n:__import__('hashlib').sha256(b).hexdigest() for n,b in helpers.items()},
'sharedUiFilesAlreadyInSystem':omitted_ui if args.differential_ui else 0,

View file

@ -5,6 +5,8 @@ import argparse
import json
import os
from pathlib import Path
import shlex
import shutil
import subprocess
import uuid
from persistent_state import FEATURES, digest
@ -15,6 +17,8 @@ def main():
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--system-image', type=Path, required=True)
parser.add_argument('--size-mib', type=int, default=2048)
parser.add_argument('--profile-dir', type=Path,
help='Copy an existing droidian-trial browser profile into a fresh P23 state')
args = parser.parse_args()
if args.size_mib < 512 or args.size_mib > 8192:
parser.error('Choose 512–8192 MiB')
@ -31,6 +35,23 @@ def main():
for directory in ('upper','work'):
(seed/directory).mkdir(mode=0o755 if directory == 'upper' else 0o700)
(seed/'upper').chmod(0o755)
migrated = []
opt_layer = None
if args.profile_dir:
profile = args.profile_dir.resolve(strict=True)
if not profile.is_dir() or profile.is_symlink():
raise ValueError('Profile source must be a real directory')
opt_layer = seed/'upper/opt'
vulpes_layer = opt_layer/'vulpes'
profiles_layer = vulpes_layer/'profiles'
profiles_layer.mkdir(parents=True)
opt_layer.chmod(0o755)
vulpes_layer.chmod(0o755)
profiles_layer.chmod(0o755)
target = profiles_layer/'droidian-trial'
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(profile, target, symlinks=True)
migrated = [vulpes_layer, profiles_layer, target, *target.rglob('*')]
identifier = str(uuid.uuid4())
image = out/(identifier+'.ext4')
with image.open('xb') as stream:
@ -42,12 +63,23 @@ def main():
for field in ('uid','gid'):
subprocess.run(['debugfs','-w','-R',f'set_inode_field {name} {field} 0',str(image)],
check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
if migrated:
# Gaia runs as the droidian account (UID/GID 32011) on Sargo.
commands = out/'profile-ownership.debugfs'
commands.write_text(''.join(
f'set_inode_field {shlex.quote("/upper/" + str(path.relative_to(seed/"upper")))} {field} 32011\n'
for path in migrated for field in ('uid','gid')) +
'set_inode_field /upper/opt uid 0\nset_inode_field /upper/opt gid 0\n')
subprocess.run(['debugfs','-w','-f',str(commands),str(image)],check=True,
stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
commands.unlink()
check = subprocess.run(['e2fsck','-f','-n',str(image)],text=True,capture_output=True)
(out/'filesystem-check.txt').write_text(check.stdout+check.stderr)
if check.returncode:raise RuntimeError('Data image failed filesystem verification')
manifest = {'schema':1,'device':'sargo','uuid':identifier,'image':image.name,
'imageSha256':digest(image),'bytes':image.stat().st_size,
'systemImageSha256':system['imageSha256'],'filesystemCheckPassed':True,
'migratedProfile':bool(args.profile_dir),
'scope':'Private persistent overlay; containing filesystem retained; no partition formatting'}
(out/'state.json').write_text(json.dumps(manifest,indent=2)+'\n')
print(out/'state.json')

View file

@ -2,6 +2,7 @@
# SPDX-License-Identifier: MPL-2.0
"""Read readiness, never dial, send messages, enter PINs or open camera nodes."""
import json
import os
from pathlib import Path
import subprocess
import time
@ -38,6 +39,150 @@ def selected(values, names):
if key in values and isinstance(values[key], dict)}
def process_cpu_snapshot():
"""Return process names and CPU ticks without command lines or arguments."""
result = {}
for entry in Path('/proc').iterdir():
if not entry.name.isdigit():
continue
try:
raw = (entry / 'stat').read_text()
# comm can contain spaces and parentheses; fields after the last )
# start at field 3 (state), so utime/stime are offsets 11 and 12.
fields = raw[raw.rfind(')') + 2:].split()
name = raw[raw.find('(') + 1:raw.rfind(')')]
result[int(entry.name)] = (name,
int(fields[11]) + int(fields[12]))
except (OSError, ValueError, IndexError):
continue
return result
def thread_cpu_snapshot():
"""Return thread names and CPU ticks; never collect command-line data."""
result = {}
for task in Path('/proc').glob('[0-9]*/task/[0-9]*'):
try:
raw = (task/'stat').read_text()
fields = raw[raw.rfind(')') + 2:].split()
name = raw[raw.find('(') + 1:raw.rfind(')')]
result[(int(task.parent.parent.name), int(task.name))] = (
name, int(fields[11]) + int(fields[12]))
except (OSError, ValueError, IndexError):
continue
return result
def cpu_idle_snapshot():
"""Read per-CPU idle-state residency counters where the kernel exports them."""
result = {}
for cpu in sorted(Path('/sys/devices/system/cpu').glob('cpu[0-9]*')):
if not cpu.name[3:].isdigit():
continue
states = []
for state in sorted((cpu/'cpuidle').glob('state[0-9]*')):
try:
states.append({key:(state/key).read_text().strip() for key in
('name','desc','usage','time','disable')
if (state/key).is_file()})
except OSError:
continue
if states:
result[cpu.name] = states
return result
def cpu_frequency_snapshot():
result = {}
for cpu in sorted(Path('/sys/devices/system/cpu').glob('cpu[0-9]*')):
if not cpu.name[3:].isdigit():
continue
path = cpu/'cpufreq'
values = {}
for key in ('scaling_governor','scaling_cur_freq','cpuinfo_cur_freq',
'scaling_min_freq','scaling_max_freq'):
try:
values[key] = (path/key).read_text().strip()
except OSError:
pass
if values:
result[cpu.name] = values
return result
def thermal_readings():
readings = {}
for zone in Path('/sys/class/thermal').glob('thermal_zone*'):
try:
label = (zone / 'type').read_text().strip()
value = int((zone / 'temp').read_text().strip())
# Linux thermal sysfs normally reports millidegrees Celsius.
readings[label or zone.name] = round(value / 1000, 1)
except (OSError, ValueError):
continue
return readings
def power_supplies():
result = {}
for supply in Path('/sys/class/power_supply').glob('*'):
data = {}
for name in ('type', 'status', 'capacity', 'voltage_now',
'current_now', 'power_now', 'temp', 'online'):
try:
data[name] = (supply / name).read_text().strip()
except OSError:
pass
if data:
result[supply.name] = data
return result
def wakeup_snapshot():
"""Read kernel wakeup counters; source labels are kernel driver names."""
for path in (Path('/sys/kernel/debug/wakeup_sources'),
Path('/sys/kernel/tracing/wakeup_sources')):
try:
lines = path.read_text().splitlines()
except OSError:
continue
if len(lines) < 2:
continue
headers = lines[0].split()
active_index = next((i for i, value in enumerate(headers)
if value.lower() == 'active_count'), None)
event_index = next((i for i, value in enumerate(headers)
if value.lower() == 'event_count'), None)
if active_index is None or event_index is None:
return None
result = {}
for line in lines[1:]:
fields = line.split()
try:
if len(fields) <= max(active_index, event_index):
continue
result[fields[0]] = {
'activeCount':int(fields[active_index]),
'eventCount':int(fields[event_index]),
}
except (ValueError, IndexError):
continue
return result
return None
def counter_delta(before, after, keys):
"""Return positive counter deltas, tolerating counters reset at reboot."""
result = {}
for name, values in (after or {}).items():
prior = (before or {}).get(name, {})
delta = {key:max(0, int(values[key])-int(prior.get(key, values[key])))
for key in keys if key in values}
if any(delta.values()):
result[name] = delta
return result
def inspect():
native = 'tundra.rescue=1' in Path('/proc/cmdline').read_text()
report = {'recordedAtUnix': time.time(), 'environment': 'tundra' if native else 'reference',
@ -80,12 +225,114 @@ def inspect():
'applicationInstalled': Path('/usr/bin/droidian-camera').exists(),
'captureTested': False,
}
report['battery'] = {}
for name in ('capacity', 'status'):
report['battery'] = power_supplies()
report['thermalCelsius'] = thermal_readings()
backlights = {}
for path in Path('/sys/class/backlight').glob('*/brightness'):
try:
report['battery'][name] = Path('/sys/class/power_supply/battery', name).read_text().strip()
backlights[path.parent.name] = (path.read_text().strip(),
(path.parent / 'max_brightness').read_text().strip())
except OSError:
continue
report['backlight'] = backlights
before = process_cpu_snapshot()
threads_before = thread_cpu_snapshot()
idle_before = cpu_idle_snapshot()
wakeups_before = wakeup_snapshot()
battery_before = power_supplies()
started = time.monotonic()
time.sleep(5)
elapsed = time.monotonic() - started
after = process_cpu_snapshot()
threads_after = thread_cpu_snapshot()
ticks_per_second = os.sysconf('SC_CLK_TCK')
cpu = []
for pid, (name, ticks) in after.items():
if pid not in before or before[pid][0] != name:
continue
delta = max(0, ticks - before[pid][1])
percent = 100 * delta / ticks_per_second / elapsed
if percent >= 1:
cpu.append({'process': name, 'percentOfOneCore': round(percent, 1)})
report['cpuSample'] = {'durationSeconds': round(elapsed, 2),
'busyProcesses': sorted(cpu,
key=lambda item: item['percentOfOneCore'],
reverse=True)[:12]}
busy_threads = []
for key, (name, ticks) in threads_after.items():
prior = threads_before.get(key)
if prior is None or prior[0] != name:
continue
percent = 100 * max(0, ticks-prior[1]) / ticks_per_second / elapsed
if percent >= 1:
busy_threads.append({'processId':key[0], 'threadId':key[1],
'thread':name,
'percentOfOneCore':round(percent,1)})
report['cpuSample']['busyThreads'] = sorted(
busy_threads, key=lambda item:item['percentOfOneCore'], reverse=True)[:20]
idle_after = cpu_idle_snapshot()
residency = {}
for cpu_name, states in idle_after.items():
previous = idle_before.get(cpu_name, [])
before_by_name = {state.get('name'):state for state in previous}
deltas = []
for state in states:
prior = before_by_name.get(state.get('name'), {})
item = {'name':state.get('name')}
for key in ('usage','time'):
if key in state:
try:
item[key+'Delta'] = max(0,int(state[key])-int(prior.get(key,state[key])))
except ValueError:
pass
if 'disable' in state:
item['disabled'] = state['disable']
deltas.append(item)
if deltas:
residency[cpu_name] = deltas
report['cpuIdle'] = {'available':bool(residency),
'statesDuringSample':residency,
'frequencyAfterSample':cpu_frequency_snapshot(),
'kernelCommandLineFlags':[
token for token in Path('/proc/cmdline').read_text().split()
if token.startswith(('lpm_levels.','cpuidle.','nohz='))],
'lowPowerParameter':None,
'systemSuspend':{}}
for key, path in {
'sleepDisabled':'/sys/module/lpm_levels/parameters/sleep_disabled',
'cpuIdleEnabled':'/sys/module/lpm_levels/parameters/enable_lpm',
'powerState':'/sys/power/state',
'memorySleep':'/sys/power/mem_sleep',
}.items():
try:
value = Path(path).read_text().strip()
if key in ('powerState','memorySleep'):
report['cpuIdle']['systemSuspend'][key] = value
else:
report['cpuIdle']['lowPowerParameter'] = {
**(report['cpuIdle']['lowPowerParameter'] or {}), key:value}
except OSError:
pass
suspend_stats = {}
for path in Path('/sys/power/suspend_stats').glob('*'):
if path.is_file():
try:
suspend_stats[path.name] = path.read_text().strip()
except OSError:
pass
report['cpuIdle']['systemSuspend']['statistics'] = suspend_stats or None
wakeups_after = wakeup_snapshot()
report['wakeupSources'] = {
'available':wakeups_after is not None,
'eventDeltas':counter_delta(wakeups_before,wakeups_after,('eventCount',)),
}
report['batteryDelta'] = {}
for supply, values in power_supplies().items():
prior = battery_before.get(supply,{})
for key in ('current_now','voltage_now','power_now','capacity','status'):
if key in values:
report['batteryDelta'].setdefault(supply,{})[key] = {
'before':prior.get(key), 'after':values[key]}
return report

View file

@ -0,0 +1,22 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MPL-2.0
"""Small helpers for keeping inherited Android boot flags out of Vulpes."""
def remove_parameters(cmdline, names):
"""Return (cleaned_cmdline, removed_tokens) for exact kernel parameter names."""
names = set(names)
kept = []
removed = []
for token in cmdline.split():
if token.split('=', 1)[0] in names:
removed.append(token)
else:
kept.append(token)
return ' '.join(kept), removed
def vulpes_cmdline(cmdline):
# This Qualcomm cpuidle parameter disables every selectable low-power
# level. It is inherited from Android and must not disable Vulpes CPU idle.
return remove_parameters(cmdline, {'lpm_levels.sleep_disabled'})

View file

@ -52,6 +52,7 @@ def main():
files['opt/firefox/vulpes/tests/BeatlesAudit.sys.mjs']=ROOT/'tundra/devices/beatles/gaia-audit.sys.mjs'
files['opt/firefox/vulpes/tests/ProbeChild.sys.mjs']=ROOT/'tests/ProbeChild.sys.mjs'
files['opt/vulpes/assets/webapps/settings.gaiamobile.org/js/modules/wifi_utils.js']=ROOT/'assets/webapps/settings.gaiamobile.org/js/modules/wifi_utils.js'
files['opt/vulpes/tundra/runtime/beatles-camera-client.py']=ROOT/'tundra/runtime/beatles-camera-client.py'
add_beatles_t9(files)
for name in ('gaia-session.py','cover-display.py','radio_control.py','radio_status.py','call_audio.py','stream_volume.py','hardware_control.py','wifi_control.py','sms_pdu.py'):
files['work/'+name]=ROOT/'tundra/devices/beatles'/name

View file

@ -17,6 +17,7 @@ import io
import tarfile
from ramdisk_reference import read_newc
from shared_ui import build_payload, compress_archive
from kernel_cmdline import vulpes_cmdline
from tundra import ROOT, checked_source, cpio, digest, save
@ -46,6 +47,8 @@ def main():
options=shlex.split(text);fields=dict(zip(options[::2],options[1::2]))
if fields['--header_version']!='0' or digest(temp/'base/kernel')!=build['kernelSha256']:
raise ValueError('Unexpected boot format or kernel')
source_cmdline=fields['--cmdline']
fields['--cmdline'],removed_kernel_parameters=vulpes_cmdline(source_cmdline)
original=read_newc((temp/'base/ramdisk').read_bytes())
if 'shared-ui.tar' not in original or 'standalone-mount.sh' not in original:
raise ValueError('Missing standalone UI overlay')
@ -112,12 +115,13 @@ def main():
for name in hashes:
target=out/'sources'/name;target.parent.mkdir(parents=True,exist_ok=True)
shutil.copy2(ROOT.parent/name,target)
for path in (Path(__file__).resolve(),ROOT/'tools/shared_ui.py',*native_sources.values(),*([ROOT/'runtime/prepare-native.py'] if args.settings_adapters else [])):
for path in (Path(__file__).resolve(),ROOT/'tools/kernel_cmdline.py',ROOT/'tools/shared_ui.py',*native_sources.values(),*([ROOT/'runtime/prepare-native.py'] if args.settings_adapters else [])):
target=out/'sources'/path.relative_to(ROOT.parent)
target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(path,target)
build['sources'][str(path.relative_to(ROOT))]=digest(path)
shutil.copy2(base/'client-key',out/'client-key')
build.update(image='boot.img',imageSha256=digest(out/'boot.img'),ramdiskSha256=digest(ramdisk),
removedKernelParameters=removed_kernel_parameters,
sharedUiHashes=hashes,builtAtUnix=int(time.time()),flashReady=False,
baseImageSha256=args.expected_sha256,uiOnlyRefresh=not args.settings_adapters,changedRamdiskEntries=changed,
scope='Private RAM Settings update; kernel and storage configuration preserved')

View file

@ -2,6 +2,7 @@
# SPDX-License-Identifier: MPL-2.0
"""Synchronize the shared host and run a bounded private Nokia Gaia trial."""
import argparse
import hashlib
import json
from pathlib import Path
import shutil
@ -15,6 +16,26 @@ OUT=ROOT/'tundra/out/beatles/gecko-156.0.1'
REMOTE='/data/local/vulpes-beatles'
def compile_if_changed(command, output, sources, signature):
"""Avoid relinking the same ARM tools on every bounded trial."""
stamp = output.with_name(output.name + '.build.json')
inputs = []
for path in sources:
stat = path.stat()
digest = hashlib.sha256(path.read_bytes()).hexdigest()
inputs.append({'path':str(path), 'size':stat.st_size, 'sha256':digest})
record = {'command':command,'signature':signature, 'inputs':inputs}
if output.is_file() and stamp.is_file():
try:
if json.loads(stamp.read_text()) == record:
print('Using cached ARM tool:', output.name, flush=True)
return
except (OSError, ValueError):
pass
subprocess.run(command, check=True)
stamp.write_text(json.dumps(record, sort_keys=True)+'\n')
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--interactive',action='store_true',help='Manual session, no automatic keypad actions; KaiOS returns within 30 minutes')
@ -23,17 +44,46 @@ def main():
parser.add_argument('--phone',action='store_true',help='Enable Nokia radio, Wi-Fi, SMS/call and hardware control brokers')
parser.add_argument('--camera-probe',action='store_true',help='Run the bounded Qualcomm HAL preview probe before Gaia starts')
parser.add_argument('--camera-preview-binary',type=Path,help='Use a private QCamera preview-dump binary for a bounded RAM-only trial')
parser.add_argument('--camera-service-probe',action='store_true',help='Test Android Camera1/CameraService as the camera system UID in a bounded RAM-only trial')
parser.add_argument('--camera-service-probe-binary',type=Path,help='Private Android API 23 Camera1 diagnostic binary')
parser.add_argument('--camera-gl-probe',action='store_true',help='Test Camera1 through EGL/GLConsumer in a bounded RAM-only trial')
parser.add_argument('--camera-gl-probe-binary',type=Path,help='Private Android API 23 EGL/GLConsumer diagnostic binary')
parser.add_argument('--camera-permission-bridge-binary',type=Path,help='Temporary camera-only Android API 23 permission controller')
parser.add_argument('--camera-permission-bridge-runtime',type=Path,help='Android API 23 C++ runtime required by the permission bridge')
parser.add_argument('--camera-broker',action='store_true',help='Run the fixed-command CameraService bridge under UID 1006 during Gaia qualification')
parser.add_argument('--no-camera-broker',action='store_true',help='Disable the CameraService bridge for a non-camera session')
parser.add_argument('--camera-broker-worker',type=Path,help='Private Android API 23 persistent Camera1 worker binary')
parser.add_argument('--camera-broker-binary',type=Path,help='Private Android API 23 local camera broker binary')
parser.add_argument('--display-session-binary',type=Path,help='Use a prebuilt compatible session supervisor')
args=parser.parse_args()
session_option=(('--phone-' if args.phone else '--interactive-')+str(args.minutes*60)+'s') if args.interactive else ('--phone-180s' if args.phone else '--temporary-90s' if (args.camera_probe or args.camera_preview_binary) else '--temporary-180s')
camera_diagnostics=(args.camera_probe, args.camera_preview_binary is not None,
args.camera_service_probe, args.camera_gl_probe)
camera_broker=args.camera_broker or (not args.no_camera_broker and not any(camera_diagnostics))
camera_modes=sum(camera_diagnostics)+(args.camera_broker is True)
if camera_modes > 1:
parser.error('Choose one camera probe per RAM trial; all use the same sensor')
session_option=(('--phone-' if args.phone else '--interactive-')+str(args.minutes*60)+'s') if args.interactive else ('--phone-180s' if args.phone else '--temporary-90s' if (args.camera_probe or args.camera_preview_binary or args.camera_service_probe or args.camera_gl_probe) else '--temporary-240s')
rows=subprocess.check_output(['adb','devices','-l'],text=True).splitlines()
devices=[r.split()[0] for r in rows if 'model:Nokia_2720_Flip' in r.split() and r.split()[1]=='device']
if len(devices)!=1:raise RuntimeError('Exactly one connected Nokia required')
adb=['adb','-s',devices[0]]
if subprocess.check_output(adb+['shell','getprop ro.product.codename'],text=True).strip()!='beatles':
raise RuntimeError('Unexpected device')
# A previous interrupted run must never make an old frame or log appear
# to belong to this trial. These paths are private diagnostics only.
stale = [REMOTE+'/camera-service-probe-once', REMOTE+'/camera-service-probe.log',
REMOTE+'/camera-gl-probe-once', REMOTE+'/camera-gl-probe.log',
REMOTE+'/camera-permission-bridge.log', REMOTE+'/qcamera-probe-once',
REMOTE+'/camera-broker-once',REMOTE+'/camera-broker.log',REMOTE+'/camera-ipc/camera.sock',
REMOTE+'/qcamera-probe.log', REMOTE+'/qcamera-preview-dump-once',
REMOTE+'/qcamera-preview-dump', REMOTE+'/rootfs/work/camera-frame.yuv',
REMOTE+'/rootfs/work/camera-photo.jpg']
cleanup=subprocess.run(adb+['shell','rm -f '+' '.join(stale)],capture_output=True,text=True)
if cleanup.returncode:
print('Could not clear every prior camera artifact:',cleanup.stderr.strip(),flush=True)
if not args.interactive:
lid=subprocess.check_output(adb+['shell','cat /sys/devices/soc.0/hall_sensor1.73/hall_status'],text=True).strip()
if lid=='1' and not (args.camera_probe or args.camera_preview_binary):
if lid=='1' and not (args.camera_probe or args.camera_preview_binary or args.camera_service_probe or args.camera_gl_probe):
raise RuntimeError('Open the Nokia flip before running keypad qualification; no session started')
ini=subprocess.check_output(adb+['shell','cat '+REMOTE+'/rootfs/opt/firefox/application.ini'],text=True)
if 'Version=156.0.1' not in ini.splitlines():raise RuntimeError('Stage Gecko 156.0.1 before launching')
@ -45,12 +95,25 @@ def main():
else:
subprocess.run(adb+['shell','rm -f '+REMOTE+'/rootfs/work/cover-animation.rgb565'],check=True,capture_output=True)
sysroot=OUT/'sysroot/rootfs'
compiler=[str(OUT/'mozbuild/clang/bin/clang'),'--target=arm-linux-gnueabihf',
'--sysroot='+str(sysroot),'--gcc-install-dir='+str(sysroot/'usr/lib/gcc/arm-linux-gnueabihf/12'),
'-fuse-ld=lld','-march=armv7-a','-mfpu=neon','-mfloat-abi=hard','-O2','-Wall','-Wextra','-Werror']
subprocess.run(compiler+['-static',str(ROOT/'tundra/probes/beatles-control-client.c'),'-o',str(OUT/'control-client')],check=True)
subprocess.run(compiler+['-static',str(ROOT/'tundra/probes/beatles-display-session.c'),'-o',str(OUT/'display-session')],check=True)
subprocess.run(compiler+[str(ROOT/'tundra/probes/beatles-keypad.c'),'-lX11','-lXtst','-o',str(OUT/'keypad')],check=True)
display_session=OUT/'display-session'
if args.display_session_binary:
if not args.display_session_binary.is_file():raise RuntimeError('Prebuilt session supervisor not found')
display_session=args.display_session_binary
else:
compiler=[str(OUT/'mozbuild/clang/bin/clang'),'--target=arm-linux-gnueabihf',
'--sysroot='+str(sysroot),'--gcc-install-dir='+str(sysroot/'usr/lib/gcc/arm-linux-gnueabihf/12'),
'-fuse-ld=lld','-march=armv7-a','-mfpu=neon','-mfloat-abi=hard','-O2','-Wall','-Wextra','-Werror']
compiler_stat=Path(compiler[0]).stat()
runtime_lock=sysroot.parent/'runtime-lock.json'
signature={'command':compiler,'compilerSize':compiler_stat.st_size,
'compilerMtimeNs':compiler_stat.st_mtime_ns,
'runtimeLockSha256':hashlib.sha256(runtime_lock.read_bytes()).hexdigest()}
compile_if_changed(compiler+['-static',str(ROOT/'tundra/probes/beatles-control-client.c'),'-o',str(OUT/'control-client')],
OUT/'control-client',[ROOT/'tundra/probes/beatles-control-client.c'],signature)
compile_if_changed(compiler+['-static',str(ROOT/'tundra/probes/beatles-display-session.c'),'-o',str(OUT/'display-session')],
OUT/'display-session',[ROOT/'tundra/probes/beatles-display-session.c'],signature)
compile_if_changed(compiler+[str(ROOT/'tundra/probes/beatles-keypad.c'),'-lX11','-lXtst','-o',str(OUT/'keypad')],
OUT/'keypad',[ROOT/'tundra/probes/beatles-keypad.c'],signature)
(OUT/'gaia-test.sh').write_text('exec /usr/bin/python3.11 /work/gaia-session.py\n')
dirs=' '.join(REMOTE+'/rootfs/'+d for d in ('opt/firefox/vulpes/tests','opt/firefox/defaults/pref','work','proc','sys','dev','tmp','run'))
subprocess.run(adb+['shell','mkdir -p '+dirs+'; chmod 1777 '+REMOTE+'/rootfs/tmp; rm -f '+REMOTE+'/display-session.done '+REMOTE+'/rootfs/work/gaia-audit.json'],capture_output=True,check=True)
@ -65,6 +128,57 @@ def main():
subprocess.run(adb+['shell','rm -f '+REMOTE+'/qcamera-preview-dump '+REMOTE+'/qcamera-preview-dump-once '+REMOTE+'/qcamera-probe.log '+REMOTE+'/rootfs/work/camera-frame.yuv '+REMOTE+'/rootfs/work/camera-photo.jpg'],check=True,capture_output=True)
subprocess.run(adb+['push',str(args.camera_preview_binary),REMOTE+'/qcamera-preview-dump'],check=True,capture_output=True)
subprocess.run(adb+['shell','chmod 755 '+REMOTE+'/qcamera-preview-dump; touch '+REMOTE+'/qcamera-preview-dump-once'],check=True,capture_output=True)
if args.camera_service_probe:
if not args.camera_service_probe_binary or not args.camera_service_probe_binary.is_file():
raise RuntimeError('--camera-service-probe requires --camera-service-probe-binary')
if not args.camera_permission_bridge_binary or not args.camera_permission_bridge_binary.is_file():
raise RuntimeError('--camera-service-probe requires --camera-permission-bridge-binary')
if not args.camera_permission_bridge_runtime or not args.camera_permission_bridge_runtime.is_file():
raise RuntimeError('--camera-service-probe requires --camera-permission-bridge-runtime')
probe='/data/local/tmp/vulpes-camera-probe-20261023'
subprocess.run(adb+['push',str(args.camera_service_probe_binary),probe],check=True,capture_output=True)
bridge='/data/local/tmp/vulpes-camera-permission-bridge'
runtime='/data/local/tmp/libc++_shared.so'
subprocess.run(adb+['push',str(args.camera_permission_bridge_binary),bridge],check=True,capture_output=True)
subprocess.run(adb+['push',str(args.camera_permission_bridge_runtime),runtime],check=True,capture_output=True)
subprocess.run(adb+['shell','chmod 755 '+probe+' '+bridge+' '+runtime+'; rm -f '+REMOTE+'/camera-permission-bridge.log; touch '+REMOTE+'/camera-service-probe-once'],check=True,capture_output=True)
if args.camera_gl_probe:
if not args.camera_gl_probe_binary or not args.camera_gl_probe_binary.is_file():
raise RuntimeError('--camera-gl-probe requires --camera-gl-probe-binary')
if not args.camera_permission_bridge_binary or not args.camera_permission_bridge_binary.is_file():
raise RuntimeError('--camera-gl-probe requires --camera-permission-bridge-binary')
if not args.camera_permission_bridge_runtime or not args.camera_permission_bridge_runtime.is_file():
raise RuntimeError('--camera-gl-probe requires --camera-permission-bridge-runtime')
probe='/data/local/tmp/vulpes-camera-gl-probe-20261023'
subprocess.run(adb+['push',str(args.camera_gl_probe_binary),probe],check=True,capture_output=True)
bridge='/data/local/tmp/vulpes-camera-permission-bridge'
runtime='/data/local/tmp/libc++_shared.so'
subprocess.run(adb+['push',str(args.camera_permission_bridge_binary),bridge],check=True,capture_output=True)
subprocess.run(adb+['push',str(args.camera_permission_bridge_runtime),runtime],check=True,capture_output=True)
subprocess.run(adb+['shell','chmod 755 '+probe+' '+bridge+' '+runtime+'; rm -f '+REMOTE+'/camera-permission-bridge.log; touch '+REMOTE+'/camera-gl-probe-once'],check=True,capture_output=True)
if camera_broker:
subprocess.run([sys.executable,str(ROOT/'tundra/tools/build-beatles-camera.py')],check=True)
broker_build=OUT/'camera-broker-build'
worker_path=args.camera_broker_worker or broker_build/'beatles-camera-worker'
broker_path=args.camera_broker_binary or broker_build/'beatles-camera-broker'
permission_bridge_path=args.camera_permission_bridge_binary or OUT/'camera-probe-runtime/beatles-camera-permission-bridge'
permission_runtime_path=args.camera_permission_bridge_runtime or OUT/'camera-probe-runtime/libc++_shared.so'
if not worker_path.is_file():
raise RuntimeError('CameraService worker binary not found: '+str(worker_path))
if not broker_path.is_file():
raise RuntimeError('CameraService broker binary not found: '+str(broker_path))
if not permission_bridge_path.is_file():
raise RuntimeError('Camera permission bridge binary not found: '+str(permission_bridge_path))
if not permission_runtime_path.is_file():
raise RuntimeError('Camera permission bridge runtime not found: '+str(permission_runtime_path))
broker_worker='/data/local/tmp/vulpes-beatles-camera-worker'
broker='/data/local/tmp/vulpes-beatles-camera-broker'
bridge='/data/local/tmp/vulpes-camera-permission-bridge'
runtime='/data/local/tmp/libc++_shared.so'
for source,target in ((worker_path,broker_worker),(broker_path,broker),
(permission_bridge_path,bridge),(permission_runtime_path,runtime)):
subprocess.run(adb+['push',str(source),target],check=True,capture_output=True)
subprocess.run(adb+['shell','chmod 755 '+broker_worker+' '+broker+' '+bridge+' '+runtime+'; mkdir -p '+REMOTE+'/camera-ipc; rm -f '+REMOTE+'/camera-ipc/camera.sock '+REMOTE+'/camera-permission-bridge.log '+REMOTE+'/camera-broker.log; touch '+REMOTE+'/camera-broker-once'],check=True,capture_output=True)
subprocess.run(adb+['push',str(ROOT/'tundra/devices/beatles/t9'),REMOTE+'/rootfs/opt/vulpes/tundra/devices/beatles/'],check=True,capture_output=True,timeout=120)
# Seed the isolated Gaia media store with small, known-good playback samples.
media=REMOTE+'/rootfs/work/gaia-profile/vulpes-media'
@ -86,7 +200,9 @@ def main():
for directory in ('host','services','adapters'):
subprocess.run(adb+['push',str(ROOT/directory),REMOTE+'/rootfs/opt/firefox/vulpes/'],check=True,capture_output=True,timeout=120)
subprocess.run(adb+['push',str(ROOT/'host'),REMOTE+'/rootfs/opt/vulpes/'],check=True,capture_output=True,timeout=120)
files=((OUT/'control-client','rootfs/work/control-client'),(OUT/'display-session','display-session'),(OUT/'keypad','rootfs/work/keypad'),
subprocess.run(adb+['shell','mkdir -p '+REMOTE+'/rootfs/opt/vulpes/tundra/runtime'],check=True,capture_output=True)
subprocess.run(adb+['push',str(ROOT/'tundra/runtime/beatles-camera-client.py'),REMOTE+'/rootfs/opt/vulpes/tundra/runtime/beatles-camera-client.py'],check=True,capture_output=True)
files=((OUT/'control-client','rootfs/work/control-client'),(display_session,'display-session'),(OUT/'keypad','rootfs/work/keypad'),
(OUT/'gaia-test.sh','rootfs/work/display-test.sh'),
(ROOT/'tundra/devices/beatles/gaia-session.py','rootfs/work/gaia-session.py'),
(ROOT/'tundra/devices/beatles/cover-display.py','rootfs/work/cover-display.py'),
@ -113,6 +229,9 @@ def main():
# Its restoration marker must not be mistaken for this trial.
subprocess.run(adb+['shell','rm -f '+REMOTE+'/display-session.done'],check=True,capture_output=True)
script='''(function(){var Cc=Components.classes,Ci=Components.interfaces;
var b=Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefBranch);
b.setBoolPref("devtools.debugger.remote-enabled",true);
Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefService).savePrefFile(null);
var f=Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);f.initWithPath(%s);
var p=Cc["@mozilla.org/process/util;1"].createInstance(Ci.nsIProcess);p.init(f);
p.runAsync([%s],1,null,false);return "started";})()''' % (json.dumps(REMOTE+'/display-session'),json.dumps(session_option))
@ -141,7 +260,7 @@ def main():
time.sleep(1)
subprocess.run(adb+['shell','touch '+REMOTE+'/stop-session'],check=True,capture_output=True)
raise RuntimeError('Manual session startup timed out; requested KaiOS restoration')
for second in range(205):
for second in range(265):
state=subprocess.check_output(adb+['shell','cat '+REMOTE+'/display-session.done'],text=True).strip()
if state=='restored':break
if second and second%30==0:print('Trial running:',second,'seconds',flush=True)
@ -149,12 +268,19 @@ def main():
else:raise RuntimeError('No restoration marker; inspect the watchdog before retrying')
report=OUT/('gaia-trial-'+time.strftime('%Y%m%d-%H%M%S'))
report.mkdir()
for relative in ('display-session.log','qcamera-probe.log','framebuffer.ppm','rootfs/work/camera-frame.yuv','rootfs/work/camera-photo.jpg','rootfs/work/Xorg.log','rootfs/work/xorg-console.log',
for relative in ('display-session.log','qcamera-probe.log','camera-service-probe.log','camera-gl-probe.log','camera-permission-bridge.log','camera-broker.log','framebuffer.ppm','rootfs/work/camera-frame.yuv','rootfs/work/camera-photo.jpg','rootfs/work/Xorg.log','rootfs/work/xorg-console.log',
'rootfs/run/tundra-hardware/status.json','rootfs/work/radio-status.log',
'rootfs/work/cover-display.log','rootfs/work/cover-preview.png','rootfs/work/cover-refresh.json','rootfs/work/lid-state.json',
'rootfs/work/keypad.log','rootfs/work/gecko.log','rootfs/work/gaia-server.log',
'rootfs/work/gaia-audit.json','rootfs/work/gaia-native.png','rootfs/work/memory-samples.jsonl'):
subprocess.run(adb+['pull',REMOTE+'/'+relative,str(report/Path(relative).name)],capture_output=True)
if args.camera_gl_probe:
for remote, name in (('/data/local/tmp/vulpes-camera-glconsumer.ppm','camera-glconsumer.ppm'),
('/data/local/tmp/vulpes-camera-gonkqueue.nv21','camera-gonkqueue.nv21'),
('/data/local/tmp/vulpes-camera-gonkqueue.txt','camera-gonkqueue.txt'),
('/data/local/tmp/vulpes-camera-callback.yuv','camera-callback.yuv'),
('/data/local/tmp/vulpes-camera-callback.jpg','camera-callback.jpg')):
subprocess.run(adb+['pull',remote,str(report/name)],capture_output=True)
service=subprocess.check_output(adb+['shell','getprop init.svc.b2g'],text=True).strip()
print('KaiOS service:',service,'Report:',report,flush=True)
# Keep both outputs from the bounded Qualcomm probe. Its still-snapshot
@ -170,6 +296,10 @@ def main():
if service!='running':raise RuntimeError('KaiOS did not restart')
if args.camera_preview_binary:
subprocess.run(adb+['shell','rm -f '+REMOTE+'/qcamera-preview-dump '+REMOTE+'/qcamera-preview-dump-once'],capture_output=True)
if args.camera_service_probe:
subprocess.run(adb+['shell','rm -f /data/local/tmp/vulpes-camera-probe-20261023 /data/local/tmp/vulpes-camera-permission-bridge /data/local/tmp/libc++_shared.so /data/local/tmp/vulpes-camera-frame.nv21'],capture_output=True)
if args.camera_gl_probe:
subprocess.run(adb+['shell','rm -f /data/local/tmp/vulpes-camera-gl-probe-20261023 /data/local/tmp/vulpes-camera-permission-bridge /data/local/tmp/libc++_shared.so'],capture_output=True)
if not (report/'gaia-audit.json').is_file():raise RuntimeError('Gaia audit did not complete')
audit=json.loads((report/'gaia-audit.json').read_text())
summary={key:value for key,value in audit.items() if key not in ('icons','layout')}

View file

@ -51,6 +51,8 @@ def selected_files():
*sorted((ROOT.parent/'services').rglob('*.mjs')),
*sorted((ROOT/'mms-codec').glob('*')),
*sorted((ROOT.parent/'assets/webapps/marketplace.gaiamobile.org').rglob('*')),
ROOT.parent/'assets/webapps/system.gaiamobile.org/style/system/system.css',
ROOT.parent/'assets/webapps/system.gaiamobile.org/style/system/initlogo.css',
ROOT.parent/'tools/installed_apps.py',
*sorted((ROOT.parent/'gaia/shared/elements/vulpes-fonts').rglob('*')),
*sorted((ROOT.parent/'overrides').rglob('*')),
@ -64,6 +66,8 @@ def selected_files():
ROOT.parent/'assets/webapps/settings.gaiamobile.org/js/panels/app_permissions_list/panel.js',
ROOT.parent/'assets/webapps/settings.gaiamobile.org/js/panels/screen_lock_passcode/panel.js',
ROOT.parent/'assets/webapps/verticalhome.gaiamobile.org/gaia_build_defer_index.js',
ROOT.parent/'assets/webapps/verticalhome.gaiamobile.org/js/init.json',
ROOT.parent/'gaia/apps/verticalhome/js/sources/application.js',
ROOT.parent/'assets/webapps/wallpaper.gaiamobile.org/style/wallpaper.css',
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_pick.js',
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_manage.js',

View file

@ -9,6 +9,18 @@ def mount_table(layout):
raise ValueError('Expected Sargo userdata, not another partition')
if not re.fullmatch(r'[0-9a-f]{64}', layout.get('firstMiBSha256', '')):
raise ValueError('Missing userdata identity checksum')
if layout.get('schema') == 2 and layout.get('table') == 'direct':
if (layout.get('partitionLabel') != 'userdata' or
layout.get('filesystem') != 'ext4' or
layout.get('storage') != 'raw-userdata-ext4' or
not re.fullmatch(r'[0-9]+:[0-9]+', layout.get('backingDevice', '')) or
not re.fullmatch(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}',
layout.get('filesystemUuid', ''))):
raise ValueError('Direct mapping requires inventoried raw ext4 userdata')
size = int(layout.get('size', 0))
if size <= 0 or size % 512:
raise ValueError('Invalid direct userdata geometry')
return None
match = re.fullmatch(r'0 ([0-9]+) linear ([0-9]+:[0-9]+) ([0-9]+)',
layout.get('table', ''))
if not match:

View file

@ -36,11 +36,34 @@ def main():
spec=importlib.util.spec_from_file_location('package_gaia',ROOT/'tundra/tools/package-beatles-gaia.py')
package=importlib.util.module_from_spec(spec);spec.loader.exec_module(package)
files=package.shared_files()
current={str(p.relative_to(ROOT)):hashlib.sha256(p.read_bytes()).hexdigest() for p in files}
out=ROOT/'tundra/out/beatles/gecko-156.0.1'
cache_path=out/'.gaia-sync-hash-cache.json'
try:
cache=json.loads(cache_path.read_text()).get('files',{})
except (OSError,ValueError,AttributeError):
cache={}
current={}
next_cache={}
reused=0
for path in files:
name=str(path.relative_to(ROOT))
stat=path.stat()
fingerprint={'device':stat.st_dev,'inode':stat.st_ino,'size':stat.st_size,
'mtime_ns':stat.st_mtime_ns,'ctime_ns':stat.st_ctime_ns}
previous=cache.get(name,{})
digest=previous.get('sha256') if all(previous.get(k)==v for k,v in fingerprint.items()) else None
if not digest:
digest=hashlib.sha256(path.read_bytes()).hexdigest()
else:
reused+=1
current[name]=digest
next_cache[name]={**fingerprint,'sha256':digest}
temporary=cache_path.with_suffix('.tmp')
temporary.write_text(json.dumps({'files':next_cache},separators=(',',':'))+'\n')
temporary.replace(cache_path)
raw=shell('cat '+REMOTE+'/work/gaia-sync.json 2>/dev/null || true')
previous=json.loads(raw).get('files',{}) if raw else {}
changed,removed=changes(current,previous)
out=ROOT/'tundra/out/beatles/gecko-156.0.1'
manifest=out/'gaia-sync.json'
manifest.write_text(json.dumps({'files':current},indent=2)+'\n')
if changed or removed:
@ -57,6 +80,6 @@ def main():
shell('rm -f '+' '.join(shlex.quote(REMOTE+'/opt/vulpes/'+p) for p in removed[start:start+100]))
subprocess.run(adb+['push',str(manifest),REMOTE+'/work/gaia-sync.json.new'],check=True,capture_output=True)
shell('mv '+REMOTE+'/work/gaia-sync.json.new '+REMOTE+'/work/gaia-sync.json')
print(json.dumps({'files':len(current),'updated':len(changed),'removed':len(removed),'profilePreserved':True}))
print(json.dumps({'files':len(current),'hashesReused':reused,'updated':len(changed),'removed':len(removed),'profilePreserved':True}))
if __name__=='__main__':main()