Release Preview 20 shared desktop, Android and Sargo updates
Restore Gaia settings and media workflows, extend native network adapters, and align the APK payload. Generate French/English changelogs and website entries from the simplified JSON source. Validation: shared JS and Tundra unit tests; desktop workflows and persistence; signed APK payload parity; Sargo RAM and installed startup with boot readback. Android runtime qualification is recorded separately.
This commit is contained in:
parent
be2cdce823
commit
1311bed5e9
170 changed files with 19698 additions and 2495 deletions
|
|
@ -140,11 +140,12 @@ def main():
|
|||
ROOT_PACKAGES.extend(['network-manager', 'wpasupplicant'])
|
||||
if args.with_hardware:
|
||||
ROOT_PACKAGES.extend(['ofono', 'pulseaudio', 'pulseaudio-utils',
|
||||
'pulseaudio-modules-droid-jb2q', 'pulseaudio-modules-droid-hidl',
|
||||
'pulseaudio-modules-droid-jb2q', 'pulseaudio-modules-droid-hidl', 'audiosystem-passthrough',
|
||||
'gstreamer1.0-droid', 'bluebinder', 'libgbinder-radio',
|
||||
'libgbinder-tools',
|
||||
'libofonobinderpluginext', 'ofono-binder-plugin',
|
||||
'ofono-configs-binder-common', 'ofono-scripts', 'ofono2mm',
|
||||
'mobile-broadband-provider-info',
|
||||
'pulseaudio-config-droid', 'droidian-camera',
|
||||
'qt5-cameraplugin-aal', 'libhybris'])
|
||||
packages = closure(installed_packages(), ROOT_PACKAGES)
|
||||
|
|
|
|||
|
|
@ -70,7 +70,8 @@ export async function run(win) {
|
|||
const prefix=app==='contacts'?'http://communications.localhost:8765/contacts/':
|
||||
app==='dialer'?'http://communications.localhost:8765/dialer/':`http://${app}.localhost:8765/`;
|
||||
function find(context) {
|
||||
if(context.currentWindowGlobal?.documentURI.spec.startsWith(prefix)) return context;
|
||||
const uri=context.currentWindowGlobal?.documentURI.spec;
|
||||
if(uri?.startsWith(prefix) || (app==='homescreen' && uri?.startsWith('http://verticalhome.localhost:8765/'))) return context;
|
||||
for(const child of context.children) {const found=find(child); if(found) return found;}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -82,22 +83,22 @@ export async function run(win) {
|
|||
sleep(30000).then(()=>{throw new Error('Content actor did not answer within 30 seconds');})
|
||||
]);
|
||||
}
|
||||
async function wait(app, code) {
|
||||
async function wait(app, code, timeout=90000) {
|
||||
dump(`TUNDRA_DROIDIAN_PROBE waiting ${app}: ${code}\n`);
|
||||
let last;
|
||||
const deadline=Date.now()+90000;
|
||||
const deadline=Date.now()+timeout;
|
||||
while(Date.now()<deadline) {
|
||||
try {last=await evaluate(app,code); if(last) return last;} catch(e) {last=String(e);}
|
||||
await sleep(500);
|
||||
}
|
||||
throw new Error(`${app}: timeout: ${last}`);
|
||||
}
|
||||
async function tap(app, selector) {
|
||||
async function tap(app, selector, timeout=90000) {
|
||||
if(app==='keyboard') selector='.keyboard-type-container[data-active] '+selector;
|
||||
const point=await wait(app, `for(const e of document.querySelectorAll(${JSON.stringify(selector)})) {
|
||||
const r=e.getBoundingClientRect(),x=r.x+r.width/2,y=r.y+r.height/2;
|
||||
if(x>0 && y>0 && x<innerWidth && y<innerHeight && e.contains(document.elementFromPoint(x,y)))return {x,y};
|
||||
}return false;`);
|
||||
}return false;`,timeout);
|
||||
await evaluate(app,point,'Tap');
|
||||
await sleep(350);
|
||||
}
|
||||
|
|
@ -111,7 +112,7 @@ export async function run(win) {
|
|||
report.checks.system=await wait('system',"return document.body?.getAttribute('ready-state')==='fullyLoaded';");
|
||||
report.checks.home=await wait('homescreen',"return document.readyState==='complete' && document.body.innerText.length>0;");
|
||||
report.checks.visible=await wait('system',"return document.visibilityState==='visible';");
|
||||
report.checks.bootLogoHidden=await wait('system',"const logo=document.getElementById('os-logo');return !logo || getComputedStyle(logo).opacity==='0';");
|
||||
report.checks.bootLogoHidden=await wait('system',"const logo=document.getElementById('os-logo');return !logo || !logo.checkVisibility({checkOpacity:true,checkVisibilityCSS:true});");
|
||||
if (Services.env.get('TUNDRA_PERSISTENT') === '1') {
|
||||
const token = Services.uuid.generateUUID().toString();
|
||||
report.persistence = await evaluate('system', `
|
||||
|
|
@ -340,6 +341,113 @@ export async function run(win) {
|
|||
}
|
||||
}
|
||||
async function requestedUiChecks() {
|
||||
const appearanceMarker = `${root}/logs/test-appearance`;
|
||||
if (await IOUtils.exists(appearanceMarker)) {
|
||||
await IOUtils.remove(appearanceMarker);
|
||||
const result={bootId:report.bootId,passed:false,checks:{}};
|
||||
let previous;
|
||||
try {
|
||||
await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
|
||||
win.document.getElementById('home').click();
|
||||
await sleep(600);
|
||||
await evaluate('system', 'await VulpesCompat.call("apps.launch",{manifestURL:"http://settings.localhost:8765/manifest.webapp"});return true;');
|
||||
await wait('settings', 'return document.readyState==="complete";');
|
||||
previous=await evaluate('settings', 'return await VulpesCompat.call("appearance.current",{});');
|
||||
await evaluate('settings', 'const s=await new Promise(r=>require(["modules/settings_service"],r));s.navigate("display");return true;');
|
||||
await wait('settings', 'return !!document.getElementById("vulpes-theme");');
|
||||
await wait('system', 'return Service.query("AppWindowManager.getActiveApp")?.origin==="http://settings.localhost:8765";');
|
||||
await sleep(800);
|
||||
await evaluate('settings', 'document.getElementById("vulpes-theme").scrollIntoView({block:"center"});return true;');
|
||||
await tap('settings','#vulpes-theme');
|
||||
result.checks.selectorOpens=await wait('settings','return !!document.getElementById("vulpes-value-selector");',15000);
|
||||
await tap('settings','#vulpes-value-selector [data-value="dark"]');
|
||||
await tap('settings','#vulpes-value-selector [data-action="confirm"]',15000);
|
||||
for(const app of ['settings','system','homescreen'])
|
||||
result.checks[app]=await wait(app, 'return document.documentElement.dataset.vulpesTheme==="dark";');
|
||||
result.checks.visibleSettings=await wait('system', 'return Service.query("AppWindowManager.getActiveApp")?.origin==="http://settings.localhost:8765";');
|
||||
result.checks.header=await evaluate('settings', 'const h=document.querySelector("#display gaia-header");return getComputedStyle(h.shadowRoot.querySelector(".inner")).backgroundColor==="rgb(37, 52, 66)" && getComputedStyle(h.querySelector("h1")).color==="rgb(241, 245, 249)";');
|
||||
result.checks.label=await evaluate('settings', 'const l=document.querySelector("#vulpes-theme-label").getBoundingClientRect(),s=document.querySelector("#vulpes-theme").getBoundingClientRect();return l.height>30 && l.bottom<=s.top;');
|
||||
await sleep(1000);
|
||||
const bitmap=await win.browsingContext.currentWindowGlobal.drawSnapshot(new win.DOMRect(0,0,win.innerWidth,win.innerHeight),1,'white');
|
||||
const canvas=win.document.createElementNS('http://www.w3.org/1999/xhtml','canvas');
|
||||
canvas.width=bitmap.width;canvas.height=bitmap.height;canvas.getContext('2d').drawImage(bitmap,0,0);bitmap.close();
|
||||
const blob=await new Promise(resolve=>canvas.toBlob(resolve,'image/png'));
|
||||
await IOUtils.write(`${root}/logs/appearance-dark.png`,new Uint8Array(await blob.arrayBuffer()));
|
||||
await evaluate('settings', 'await navigator.mozSettings.createLock().set({"vulpes.theme":"light"});return true;');
|
||||
result.checks.returnLight=await wait('homescreen', 'return document.documentElement.dataset.vulpesTheme==="light";');
|
||||
result.passed=Object.values(result.checks).every(value=>value===true);
|
||||
} catch(error) {result.error=String(error);}
|
||||
finally {
|
||||
if(previous!==undefined) await evaluate('settings', `await navigator.mozSettings.createLock().set({"vulpes.theme":${JSON.stringify(previous)}});return true;`).catch(()=>{});
|
||||
}
|
||||
await IOUtils.writeJSON(`${root}/logs/appearance.json`,result);
|
||||
}
|
||||
const followupMarker=`${root}/logs/test-settings-followup`;
|
||||
if(await IOUtils.exists(followupMarker)) {
|
||||
await IOUtils.remove(followupMarker);
|
||||
const {auditFollowup}=ChromeUtils.importESModule('resource://vulpes/host/SettingsFollowupAudit.sys.mjs');
|
||||
const save=data=>IOUtils.writeJSON(`${root}/logs/settings-followup.json`,{bootId:report.bootId,...data});
|
||||
const microphonePrincipal=Services.scriptSecurityManager.createContentPrincipal(Services.io.newURI('http://camera.localhost:8765'),{});
|
||||
const microphonePermission=Services.perms.testExactPermissionFromPrincipal(microphonePrincipal,'microphone');
|
||||
// This private marker explicitly requests a short camera/audio test.
|
||||
Services.perms.addFromPrincipal(microphonePrincipal,'microphone',Ci.nsIPermissionManager.ALLOW_ACTION,Ci.nsIPermissionManager.EXPIRE_SESSION);
|
||||
try {await auditFollowup({evaluate,wait,sleep,save,tap});}
|
||||
catch(error){await save({passed:false,error:String(error)});}
|
||||
finally {
|
||||
if(microphonePermission===Ci.nsIPermissionManager.UNKNOWN_ACTION)Services.perms.removeFromPrincipal(microphonePrincipal,'microphone');
|
||||
else Services.perms.addFromPrincipal(microphonePrincipal,'microphone',microphonePermission);
|
||||
}
|
||||
}
|
||||
const auditMarker=`${root}/logs/test-settings-all`;
|
||||
if(await IOUtils.exists(auditMarker)) {
|
||||
await IOUtils.remove(auditMarker);
|
||||
const {auditSettings}=ChromeUtils.importESModule('resource://vulpes/host/SettingsAudit.sys.mjs');
|
||||
const save=data=>IOUtils.writeJSON(`${root}/logs/settings-all.json`,{bootId:report.bootId,...data});
|
||||
try {await save(await auditSettings({evaluate,wait,sleep,save}));}
|
||||
catch(error) {await IOUtils.writeJSON(`${root}/logs/settings-all-error.json`,{bootId:report.bootId,error:String(error)});}
|
||||
}
|
||||
const settingsMarker = `${root}/logs/test-settings-navigation`;
|
||||
if (await IOUtils.exists(settingsMarker)) {
|
||||
await IOUtils.remove(settingsMarker);
|
||||
const result={bootId:report.bootId,checks:{},errors:[]};
|
||||
try {
|
||||
await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});await VulpesCompat.call("apps.launch",{manifestURL:"http://settings.localhost:8765/manifest.webapp"});return true;');
|
||||
await wait('settings','return document.readyState==="complete";');
|
||||
await evaluate('settings','window.__vulpesDiagnostics=[];return true;');
|
||||
for(const [parent,id,selector] of [
|
||||
['root','sim-manager','#menuItem-simManager'],
|
||||
['root','call','#call-settings a'],
|
||||
['root','messaging','#menuItem-messagingSettings'],
|
||||
['wifi','wifi-manageNetworks','a[href="#wifi-manageNetworks"]'],
|
||||
['wifi','wifi-manageCertificates','a[href="#wifi-manageCertificates"]']]) {
|
||||
try {
|
||||
await evaluate('settings',`const s=await new Promise(r=>require(["modules/settings_service"],r));await s.navigate(${JSON.stringify(parent)});return true;`);
|
||||
await sleep(700);
|
||||
await evaluate('settings',`document.querySelector(${JSON.stringify(selector)}).scrollIntoView({block:"center"});return true;`);
|
||||
await sleep(200);
|
||||
const start=Date.now();
|
||||
await tap('settings',selector,15000);
|
||||
await wait('settings',`return document.querySelector('section.current')?.id===${JSON.stringify(id)};`,15000);
|
||||
await sleep(800);
|
||||
result.checks[id]={opened:true,elapsedMs:Math.round(Date.now()-start),detail:await evaluate('settings',`const p=document.getElementById(${JSON.stringify(id)});return {textLength:p.innerText.length,simCards:p.querySelectorAll('.sim-card').length,explanation:!!p.querySelector('[data-vulpes-unavailable]')};`)};
|
||||
if(id==='messaging') {
|
||||
await wait('settings',`return document.querySelector('#messaging [name="ril.sms.requestStatusReport.enabled"]')?.dataset.vulpesSupported==="true";`,15000);
|
||||
const old=await evaluate('settings','return (await VulpesCompat.call("platform.mobileSettings",{action:"messages"})).deliveryReports;');
|
||||
try {
|
||||
await tap('settings','#messaging [name="ril.sms.requestStatusReport.enabled"]',15000);
|
||||
result.checks.smsDeliveryReports=await wait('settings',`return (await VulpesCompat.call("platform.mobileSettings",{action:"messages"})).deliveryReports===${!old};`,15000);
|
||||
} finally {
|
||||
await evaluate('settings',`await VulpesCompat.call("platform.mobileSettings",{action:"deliveryReports",enabled:${old}});await navigator.mozSettings.createLock().set({"ril.sms.requestStatusReport.enabled":${old}});return true;`);
|
||||
}
|
||||
}
|
||||
} catch(error) {result.checks[id]={error:String(error)};}
|
||||
}
|
||||
result.errors=await evaluate('settings','return window.__vulpesDiagnostics;');
|
||||
result.passed=Object.values(result.checks).every(v=>v===true || v.opened) && !result.errors.some(e=>e.kind==="error" || e.kind==="rejection");
|
||||
await evaluate('settings','const s=await new Promise(r=>require(["modules/settings_service"],r));await s.navigate("display");return true;');
|
||||
} catch(error) {result.error=String(error);result.passed=false;}
|
||||
await IOUtils.writeJSON(`${root}/logs/settings-navigation.json`,result);
|
||||
}
|
||||
await requestedControls();
|
||||
const marketplaceMarker = `${root}/logs/test-marketplace-views`;
|
||||
if (await IOUtils.exists(marketplaceMarker)) {
|
||||
|
|
@ -423,20 +531,50 @@ export async function run(win) {
|
|||
}
|
||||
await IOUtils.writeJSON(`${root}/logs/tickets-20261005.json`,result);
|
||||
}
|
||||
const bluetoothMarker = `${root}/logs/test-bluetooth`;
|
||||
if (await IOUtils.exists(bluetoothMarker)) {
|
||||
await IOUtils.remove(bluetoothMarker);
|
||||
const result={bootId:report.bootId,passed:false,checks:{}};
|
||||
await IOUtils.writeJSON(`${root}/logs/bluetooth.json`,result);
|
||||
let before;
|
||||
try {
|
||||
await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});return true;");
|
||||
await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://settings.localhost:8765/manifest.webapp'});return true;");
|
||||
await wait('settings', "return !!navigator.mozBluetooth?.defaultAdapter;");
|
||||
before=await evaluate('settings', "return navigator.mozBluetooth.defaultAdapter.state==='enabled';");
|
||||
await evaluate('settings', "require(['modules/settings_service'],s=>s.navigate('bluetooth'));return true;");
|
||||
result.checks.originalPanel=await wait('settings', "return !!document.querySelector('#bluetooth.current .bluetooth-status');");
|
||||
await evaluate('settings', "require(['modules/bluetooth/bluetooth_context'],c=>{window.__btContext=c;c.setEnabled(true);});return true;");
|
||||
result.checks.enabled=await wait('settings', "return navigator.mozBluetooth.defaultAdapter.state==='enabled';");
|
||||
await evaluate('settings', "window.__btFound=new Set();window.__btScanPromise=navigator.mozBluetooth.defaultAdapter.startDiscovery().then(h=>h.ondevicefound=e=>__btFound.add(e.device.address));return true;");
|
||||
result.checks.discovering=await wait('settings', "return navigator.mozBluetooth.defaultAdapter.discovering;");
|
||||
await sleep(7000);
|
||||
result.deviceCount=await evaluate('settings', "return __btFound.size;");
|
||||
await evaluate('settings', "await navigator.mozBluetooth.defaultAdapter.stopDiscovery();return true;");
|
||||
result.checks.stopped=await wait('settings', "return !navigator.mozBluetooth.defaultAdapter.discovering;");
|
||||
result.passed=Object.values(result.checks).every(Boolean);
|
||||
} catch(error) {result.error=String(error);}
|
||||
finally {
|
||||
if(before!==undefined)await evaluate('settings', `await navigator.mozBluetooth.defaultAdapter.${before?'enable':'disable'}();return true;`).catch(()=>{});
|
||||
win.document.getElementById('home').click();
|
||||
}
|
||||
await IOUtils.writeJSON(`${root}/logs/bluetooth.json`,result);
|
||||
}
|
||||
const cameraMarker = `${root}/logs/test-camera`;
|
||||
if (await IOUtils.exists(cameraMarker)) {
|
||||
await IOUtils.remove(cameraMarker);
|
||||
const cameraReport = {bootId:report.bootId, imagesRetained:false, passed:false};
|
||||
await IOUtils.writeJSON(`${root}/logs/camera.json`,cameraReport);
|
||||
try {
|
||||
await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});return true;");
|
||||
await wait('system', "return !Service.query('locked');");
|
||||
await evaluate('system', "await VulpesCompat.call('apps.launch',{manifestURL:'http://camera.localhost:8765/manifest.webapp'});return true;");
|
||||
await wait('camera', "return !!window.app?.camera?.mozCamera && document.querySelector('video')?.videoWidth>0;");
|
||||
await wait('camera', "return !!window.app?.camera?.mozCamera && app.camera.mozCamera.vulpesPreviewCanvas?.isConnected;");
|
||||
// Gaia remembers the selected lens; establish a known starting state.
|
||||
await evaluate('camera', "if(app.settings.cameras.selected('key')!=='back')app.settings.cameras.next();return true;");
|
||||
await wait('camera', "return app.settings.cameras.selected('key')==='back' && !!app.camera.mozCamera && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
|
||||
await wait('camera', "return app.settings.cameras.selected('key')==='back' && !!app.camera.mozCamera && !app.camera.isBusy && app.camera.mozCamera.vulpesPreviewCanvas?.isConnected;");
|
||||
cameraReport.gaia = await evaluate('camera', `
|
||||
const video=document.querySelector('video');
|
||||
const video=app.camera.mozCamera.vulpesPreviewCanvas;
|
||||
const camera=app.camera.mozCamera;
|
||||
window.__cameraBeforeSwitch=camera;
|
||||
const capabilities=camera.capabilities;
|
||||
|
|
@ -456,42 +594,62 @@ export async function run(win) {
|
|||
const photo=await camera.takePicture();
|
||||
const bitmap=await createImageBitmap(photo);
|
||||
const picture={width:bitmap.width,height:bitmap.height};bitmap.close();
|
||||
return {width:video.videoWidth,height:video.videoHeight,native:camera.vulpesNative===true,
|
||||
return {width:video.width,height:video.height,native:camera.vulpesNative===true,
|
||||
picture,focus,focusError,zoom:testedZoom,focusAreas:capabilities.maxFocusAreas,
|
||||
jpegBytes:photo.size,jpegType:photo.type,errors:window.__vulpesDiagnostics};
|
||||
`);
|
||||
cameraReport.performance = await evaluate('camera', `
|
||||
const stream=app.camera.mozCamera, before=stream.vulpesTiming?.frames||0;
|
||||
const video=app.camera.mozCamera.vulpesPreviewCanvas;
|
||||
let decoded=0,frameCallback;const onVideoFrame=()=>{decoded++;frameCallback=requestAnimationFrame(onVideoFrame);};
|
||||
frameCallback=requestAnimationFrame(onVideoFrame);
|
||||
const start=performance.now();await new Promise(r=>setTimeout(r,4000));
|
||||
const elapsed=performance.now()-start;
|
||||
if(frameCallback!==undefined)cancelAnimationFrame(frameCallback);
|
||||
const hud=document.querySelector('.hud');
|
||||
const button=document.querySelector('.js-camera');
|
||||
return {fps:(stream.vulpesTiming.frames-before)*1000/elapsed,
|
||||
conversionMs:stream.vulpesTiming.conversionMs/stream.vulpesTiming.frames,
|
||||
animationFps:decoded*1000/elapsed,
|
||||
transferMs:stream.vulpesTiming.transferMs/stream.vulpesTiming.frames,
|
||||
switchVisible:getComputedStyle(button).opacity!=='0' && hud.getAttribute('camera-enabled')==='true',
|
||||
flashVisible:getComputedStyle(document.querySelector('.js-flash')).visibility==='visible'};
|
||||
`);
|
||||
await IOUtils.writeJSON(`${root}/logs/camera.json`,cameraReport);
|
||||
await evaluate('camera', "document.querySelector('.js-camera').click();return true;");
|
||||
await wait('camera', "return !!app.camera.mozCamera && app.camera.mozCamera!==window.__cameraBeforeSwitch && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
|
||||
await wait('camera', "return !!app.camera.mozCamera && app.camera.mozCamera!==window.__cameraBeforeSwitch && !app.camera.isBusy && app.camera.mozCamera.vulpesPreviewCanvas?.isConnected;");
|
||||
cameraReport.switched = await evaluate('camera', `
|
||||
const photo=await app.camera.mozCamera.takePicture();
|
||||
window.__cameraBeforeHome=app.camera.mozCamera;
|
||||
return {camera:app.settings.cameras.selected('key'),jpegBytes:photo.size,
|
||||
previousStopped:window.__cameraBeforeSwitch.getTracks().every(t=>t.readyState==='ended')};
|
||||
previousStopped:window.__cameraBeforeSwitch.vulpesClosed===true};
|
||||
`);
|
||||
cameraReport.roundTrips = [];
|
||||
for (let i=0; i<4; i++) {
|
||||
for (let i=0; i<8; i++) {
|
||||
await evaluate('camera', "window.__switchFrom=app.camera.mozCamera;return true;");
|
||||
await tap('camera', '.js-camera');
|
||||
await wait('camera', "return !!app.camera.mozCamera && app.camera.mozCamera!==window.__switchFrom && !app.camera.isBusy && document.querySelector('.viewfinder video')?.videoWidth>0;");
|
||||
await wait('camera', "return !!app.camera.mozCamera && app.camera.mozCamera!==window.__switchFrom && !app.camera.isBusy && app.camera.mozCamera.vulpesPreviewCanvas?.isConnected;");
|
||||
cameraReport.roundTrips.push(await evaluate('camera', `
|
||||
const camera=app.camera.mozCamera, before=camera.vulpesTiming.frames;
|
||||
await new Promise(r=>setTimeout(r,700));
|
||||
const video=app.camera.mozCamera.vulpesPreviewCanvas;
|
||||
let decoded=0,frameCallback;const onVideoFrame=()=>{decoded++;frameCallback=requestAnimationFrame(onVideoFrame);};
|
||||
frameCallback=requestAnimationFrame(onVideoFrame);
|
||||
await new Promise(r=>setTimeout(r,1500));
|
||||
if(frameCallback!==undefined)cancelAnimationFrame(frameCallback);
|
||||
const canvas=document.createElement('canvas');canvas.width=16;canvas.height=16;
|
||||
const ctx=canvas.getContext('2d');ctx.drawImage(video,0,0,16,16);
|
||||
const pixels=ctx.getImageData(0,0,16,16).data;
|
||||
let light=0;for(let n=0;n<pixels.length;n+=4)light+=pixels[n]+pixels[n+1]+pixels[n+2];
|
||||
return {camera:app.settings.cameras.selected('key'),
|
||||
frames:camera.vulpesTiming.frames-before,
|
||||
previousStopped:window.__switchFrom.getTracks().every(t=>t.readyState==='ended')};
|
||||
animationFrames:decoded,
|
||||
width:video.getBoundingClientRect().width,
|
||||
meanLight:light/(16*16*3),
|
||||
visible:getComputedStyle(document.querySelector('.viewfinder')).opacity!=='0',
|
||||
previousStopped:window.__switchFrom.vulpesClosed===true};
|
||||
`));
|
||||
}
|
||||
await IOUtils.writeJSON(`${root}/logs/camera.json`,cameraReport);
|
||||
await evaluate('camera', 'window.__cameraBeforeHome=app.camera.mozCamera;return true;');
|
||||
// Test Gaia's Home action, independent of a held pointer on the shell button.
|
||||
await evaluate('camera', "window.__cameraFilesBefore=new Set((await VulpesCompat.call('media.request',{type:'pictures',operation:'enumerate'})).map(f=>f.name));return true;");
|
||||
|
|
@ -511,8 +669,8 @@ export async function run(win) {
|
|||
await evaluate('system', "dispatchEvent(new CustomEvent('home'));return true;");
|
||||
cameraReport.afterHome = await evaluate('system', "await new Promise(r=>setTimeout(r,1000));return {active:Service.query('getTopMostWindow')?.origin, top:Service.query('getTopMostUI')?.name, locked:Service.query('locked'), screen:ScreenManager.screenEnabled};");
|
||||
cameraReport.hidden = await wait('camera', 'return document.hidden;');
|
||||
cameraReport.stopped = await wait('camera', "return window.__cameraBeforeHome.getTracks().every(t=>t.readyState==='ended');");
|
||||
cameraReport.passed=cameraReport.captureStored && cameraReport.galleryOpened && cameraReport.gaia.native && cameraReport.gaia.picture.width*cameraReport.gaia.picture.height>10000000 && cameraReport.gaia.focus && cameraReport.gaia.zoom>1 && cameraReport.gaia.jpegBytes>1000 && cameraReport.gaia.jpegType==='image/jpeg' && cameraReport.switched.jpegBytes>1000 && cameraReport.switched.previousStopped && cameraReport.stopped && cameraReport.roundTrips.every(r=>r.frames>0 && r.previousStopped);
|
||||
cameraReport.stopped = await wait('camera', "return window.__cameraBeforeHome.vulpesClosed===true;");
|
||||
cameraReport.passed=cameraReport.captureStored && cameraReport.galleryOpened && cameraReport.gaia.native && cameraReport.gaia.picture.width*cameraReport.gaia.picture.height>10000000 && cameraReport.gaia.focus && cameraReport.gaia.zoom>1 && cameraReport.gaia.jpegBytes>1000 && cameraReport.gaia.jpegType==='image/jpeg' && cameraReport.switched.jpegBytes>1000 && cameraReport.switched.previousStopped && cameraReport.stopped && cameraReport.roundTrips.length===8 && cameraReport.roundTrips.every(r=>r.frames>0 && r.animationFrames>0 && r.width>0 && r.visible && r.previousStopped);
|
||||
} catch(error) { cameraReport.error=String(error); }
|
||||
finally { win.document.getElementById('home')?.click(); }
|
||||
await IOUtils.writeJSON(`${root}/logs/camera.json`, cameraReport);
|
||||
|
|
@ -571,7 +729,7 @@ export async function run(win) {
|
|||
const smsReport = {bootId:report.bootId, passed:false, sent:false};
|
||||
try {
|
||||
await evaluate('system', "ScreenManager.turnScreenOn(true);if(Service.query('locked'))await Service.request('unlock',{forcibly:true});await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;");
|
||||
await wait('sms', "return !!window.Navigation && !!window.MessageManager;");
|
||||
await wait('sms', "return typeof window.Navigation?.toPanel === 'function' && !!window.MessageManager;");
|
||||
smsReport.threads = await evaluate('sms', `
|
||||
const threads=await new Promise((resolve,reject)=>{const rows=[];const c=navigator.mozMobileMessage.getThreads();c.onsuccess=()=>{if(c.done)resolve(rows);else{rows.push(c.result);c.continue();}};c.onerror=()=>reject(c.error);});
|
||||
window.__smsReviewThreads=threads.map(t=>t.id);
|
||||
|
|
@ -648,7 +806,7 @@ export async function run(win) {
|
|||
await evaluate('system', 'ScreenManager.turnScreenOn(true);if(Service.query("locked"))await Service.request("unlock",{forcibly:true});return true;');
|
||||
await sleep(1000);
|
||||
await evaluate('system', `await VulpesCompat.call('apps.launch',{manifestURL:'http://sms.localhost:8765/manifest.webapp'});return true;`);
|
||||
await wait('sms','return !!window.Navigation;');
|
||||
await wait('sms',"return typeof window.Navigation?.toPanel === 'function';");
|
||||
await evaluate('sms','await Navigation.toPanel("thread-list");await Navigation.toPanel("composer");return true;');
|
||||
await evaluate('sms','if(ConversationView.recipients.length || document.querySelector(".recipient[contenteditable]").textContent.trim() || Compose.getText())throw Error("TEST_DRAFT_PRESENT");return true;');
|
||||
await tap('sms','.recipient[contenteditable]');
|
||||
|
|
@ -656,12 +814,27 @@ export async function run(win) {
|
|||
await evaluate('keyboard',`await navigator.mozInputMethod.inputcontext.replaceSurroundingText(${JSON.stringify(request.recipient)},0,0);return true;`);
|
||||
await tap('sms','#messages-input');
|
||||
await wait('keyboard','return navigator.mozInputMethod?.inputcontext?.inputType==="text";');
|
||||
const body='Vulpes OS - test SMS '+new Date().toISOString();
|
||||
const isMms=request.type==='mms';
|
||||
const body='Vulpes OS - test '+(isMms?'MMS':'SMS')+' '+new Date().toISOString();
|
||||
const filename='vulpes-test-'+Date.now()+'.png';
|
||||
await evaluate('keyboard',`await navigator.mozInputMethod.inputcontext.replaceSurroundingText(${JSON.stringify(body)},0,0);return true;`);
|
||||
if(isMms) {
|
||||
await evaluate('sms',`const c=document.createElement('canvas');c.width=c.height=64;
|
||||
const ctx=c.getContext('2d');ctx.fillStyle='#197dd2';ctx.fillRect(0,0,64,64);
|
||||
const blob=await new Promise(resolve=>c.toBlob(resolve,'image/png'));
|
||||
Compose.append(new Attachment(blob,{name:${JSON.stringify(filename)},isDraft:true}));return true;`);
|
||||
await wait('sms',"return Compose.type==='mms';");
|
||||
}
|
||||
await wait('sms','return !document.querySelector("#messages-send-button").disabled;');
|
||||
result.preSend=await evaluate('sms',`return {composer:Navigation.isCurrentPanel('composer'),
|
||||
recipientMatch:JSON.stringify(ConversationView.recipients.numbers.map(n=>navigator.mozPhoneNumberService.normalize(n)))===JSON.stringify([${JSON.stringify(request.recipient)}]),
|
||||
recipientCount:ConversationView.recipients.numbers.length,
|
||||
result.preSend=await evaluate('sms',`
|
||||
// Gaia also accepts a number still being edited when Send is pressed.
|
||||
const numbers=ConversationView.recipients.numbers.slice();
|
||||
const pending=document.querySelector('.recipient[contenteditable]')?.textContent.trim();
|
||||
const normalize=n=>navigator.mozPhoneNumberService.normalize(n);
|
||||
if(pending && !numbers.some(n=>normalize(n)===normalize(pending)))numbers.push(pending);
|
||||
return {composer:Navigation.isCurrentPanel('composer'),
|
||||
recipientMatch:JSON.stringify(numbers.map(n=>navigator.mozPhoneNumberService.normalize(n)))===JSON.stringify([${JSON.stringify(request.recipient)}]),
|
||||
recipientCount:numbers.length,
|
||||
bodyMatch:Compose.getText()===${JSON.stringify(body)},bodyLength:Compose.getText().length,
|
||||
expectedLength:${body.length}};`);
|
||||
if(!result.preSend.composer || !result.preSend.recipientMatch || !result.preSend.bodyMatch)
|
||||
|
|
@ -670,9 +843,14 @@ export async function run(win) {
|
|||
result.submitted=await wait('sms',`return await new Promise((resolve,reject)=>{
|
||||
const cursor=navigator.mozMobileMessage.getMessages(null,true);
|
||||
cursor.onsuccess=()=>{const m=cursor.result;if(!m){resolve(false);return;}
|
||||
if(m.body===${JSON.stringify(body)}){if(m.delivery==='error'){reject(Error('MODEM_TRANSMISSION_FAILED'));return;}resolve(m.delivery==='sent');return;}cursor.continue();};
|
||||
const match=${isMms} ? m.type==='mms' && m.attachments.some(a=>a.location===${JSON.stringify(filename)}) : m.body===${JSON.stringify(body)};
|
||||
if(match){window.__sentTestId=m.id;if(m.delivery==='error'){reject(Error('MODEM_TRANSMISSION_FAILED'));return;}resolve(m.delivery==='sent');return;}cursor.continue();};
|
||||
cursor.onerror=()=>reject(cursor.error);
|
||||
});`);
|
||||
if(isMms) {
|
||||
result.operatorAccepted=result.submitted;
|
||||
result.conversationVisible=await wait('sms',"return !!document.querySelector('#message-'+window.__sentTestId+' .attachment-container');");
|
||||
}
|
||||
} catch(error) {result.error=String(error);}
|
||||
finally {win.document.getElementById('home').click();}
|
||||
await IOUtils.writeJSON(`${root}/logs/send-sms.json`,result);
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ Services.obs.addObserver({observe(win) {
|
|||
profile.mkdir(parents=True, exist_ok=True)
|
||||
prefs = {'browser.shell.checkDefaultBrowser':False,
|
||||
'font.default.x-western':'sans-serif',
|
||||
'widget.wayland.vsync.enabled':True, 'layout.frame_rate':-1,
|
||||
'dom.w3c_touch_events.legacy_apis.enabled':True,
|
||||
'font.name.sans-serif.x-western':'Fira Sans',
|
||||
'browser.dom.window.dump.enabled':True,
|
||||
|
|
|
|||
674
tundra/mms-codec/COPYING
Normal file
674
tundra/mms-codec/COPYING
Normal file
|
|
@ -0,0 +1,674 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
10
tundra/mms-codec/go.mod
Normal file
10
tundra/mms-codec/go.mod
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
module vulpes.local/mms-codec
|
||||
|
||||
go 1.22
|
||||
|
||||
require gitlab.com/ubports/development/core/nuntium v0.0.0-20260827194050-d051757417a4
|
||||
|
||||
require (
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
gitlab.com/ubports/development/core/go-ldm v0.0.0-20240829085643-4b8905597dac // indirect
|
||||
)
|
||||
12
tundra/mms-codec/go.sum
Normal file
12
tundra/mms-codec/go.sum
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
gitlab.com/ubports/development/core/go-ldm v0.0.0-20240829085643-4b8905597dac h1:qwJa6TLO/c3SnjyXyxVRMEpHY2AKAcoSOgzuHmXp3y0=
|
||||
gitlab.com/ubports/development/core/go-ldm v0.0.0-20240829085643-4b8905597dac/go.mod h1:pucSy3AIvCddSTBSWMi0ksVk1f14RLUoTTo/6ir9WEg=
|
||||
gitlab.com/ubports/development/core/nuntium v0.0.0-20260827194050-d051757417a4 h1:pObcbcl2WrOp8Mdw1/vs72xFZX/WAmHYk2GIToeLbBo=
|
||||
gitlab.com/ubports/development/core/nuntium v0.0.0-20260827194050-d051757417a4/go.mod h1:FccedFuGf6fPathxJjT5POSHItV2A2imvERNIdoBGdA=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
145
tundra/mms-codec/main.go
Normal file
145
tundra/mms-codec/main.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
// This separate helper uses UBports Nuntium's MMS/WAP codecs over JSON stdin.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitlab.com/ubports/development/core/nuntium/mms"
|
||||
"gitlab.com/ubports/development/core/nuntium/ofono"
|
||||
)
|
||||
|
||||
const limit = 600 * 1024
|
||||
|
||||
type Part struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Data []byte `json:"data"`
|
||||
}
|
||||
type Request struct {
|
||||
Operation string `json:"operation"`
|
||||
Data []byte `json:"data"`
|
||||
Recipients []string `json:"recipients"`
|
||||
Subject string `json:"subject"`
|
||||
Transaction string `json:"transaction"`
|
||||
Parts []Part `json:"parts"`
|
||||
}
|
||||
|
||||
func run(r Request) (any, error) {
|
||||
if len(r.Data) > limit {
|
||||
return nil, errors.New("MMS_TOO_LARGE")
|
||||
}
|
||||
switch r.Operation {
|
||||
case "encode":
|
||||
if len(r.Recipients) < 1 || len(r.Recipients) > 10 || len(r.Parts) > 20 {
|
||||
return nil, errors.New("INVALID_MMS")
|
||||
}
|
||||
parts := []*mms.Attachment{}
|
||||
total := 0
|
||||
for _, p := range r.Parts {
|
||||
total += len(p.Data)
|
||||
if total > limit || len(p.ID) > 100 || len(p.Type) > 100 {
|
||||
return nil, errors.New("MMS_TOO_LARGE")
|
||||
}
|
||||
parts = append(parts, &mms.Attachment{ContentId: p.ID, ContentLocation: p.ID, Name: p.ID, MediaType: p.Type, Data: p.Data})
|
||||
}
|
||||
message := mms.NewMSendReq(r.Recipients, parts, false)
|
||||
message.Subject = r.Subject
|
||||
var b bytes.Buffer
|
||||
if err := mms.NewEncoder(&b).Encode(message); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b.Len() > limit {
|
||||
return nil, errors.New("MMS_TOO_LARGE")
|
||||
}
|
||||
return map[string]any{"data": b.Bytes(), "transaction": message.TransactionId}, nil
|
||||
case "response":
|
||||
message := mms.NewMSendConf()
|
||||
if err := mms.NewDecoder(r.Data).Decode(message); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if message.Type != mms.TYPE_SEND_CONF || message.TransactionId != r.Transaction {
|
||||
return nil, errors.New("MMS_INVALID_RESPONSE")
|
||||
}
|
||||
return map[string]any{"accepted": message.ResponseStatus == mms.ResponseStatusOk, "status": message.ResponseStatus, "messageId": message.MessageId}, nil
|
||||
case "notification":
|
||||
if len(r.Data) < 4 {
|
||||
return nil, errors.New("MMS_INVALID_PUSH")
|
||||
}
|
||||
push := &ofono.PushPDU{}
|
||||
if err := ofono.NewDecoder(r.Data).Decode(push); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if push.ApplicationId != mms.PUSH_APPLICATION_ID || push.ContentType != mms.VND_WAP_MMS_MESSAGE {
|
||||
return nil, errors.New("MMS_INVALID_PUSH")
|
||||
}
|
||||
message := mms.NewMNotificationInd(time.Now())
|
||||
if err := mms.NewDecoder(push.Data).Decode(message); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if message.Type != mms.TYPE_NOTIFICATION_IND {
|
||||
return nil, errors.New("MMS_INVALID_PUSH")
|
||||
}
|
||||
return map[string]any{"transaction": message.TransactionId, "url": message.ContentLocation, "sender": strings.Split(message.From, "/TYPE=")[0], "subject": message.Subject, "expires": message.Expiry.Unix(), "size": message.Size}, nil
|
||||
case "retrieve":
|
||||
message := mms.NewMRetrieveConf("")
|
||||
if err := mms.NewDecoder(r.Data).Decode(message); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if message.Type != mms.TYPE_RETRIEVE_CONF || (message.RetrieveStatus != 0 && message.RetrieveStatus != 128) {
|
||||
return nil, errors.New("MMS_INVALID_CONTENT")
|
||||
}
|
||||
parts := []Part{}
|
||||
for _, p := range message.Attachments {
|
||||
id := p.ContentLocation
|
||||
if id == "" {
|
||||
id = strings.Trim(p.ContentId, "<>")
|
||||
}
|
||||
parts = append(parts, Part{id, p.MediaType, p.Data})
|
||||
}
|
||||
receivers := []string{}
|
||||
for _, n := range message.To {
|
||||
receivers = append(receivers, strings.Split(n, "/TYPE=")[0])
|
||||
}
|
||||
return map[string]any{"sender": strings.Split(message.From, "/TYPE=")[0], "receivers": receivers, "subject": message.Subject, "parts": parts, "timestamp": message.Date * 1000}, nil
|
||||
case "ack":
|
||||
if r.Transaction == "" || len(r.Transaction) > 200 {
|
||||
return nil, errors.New("MMS_INVALID_TRANSACTION")
|
||||
}
|
||||
message := mms.NewMNotifyRespInd()
|
||||
message.TransactionId = r.Transaction
|
||||
message.Version = mms.MMS_MESSAGE_VERSION_1_1
|
||||
message.Status = mms.STATUS_RETRIEVED
|
||||
var b bytes.Buffer
|
||||
if err := mms.NewEncoder(&b).Encode(message); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"data": b.Bytes()}, nil
|
||||
}
|
||||
return nil, errors.New("MMS_INVALID_OPERATION")
|
||||
}
|
||||
func main() {
|
||||
log.SetOutput(io.Discard)
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
json.NewEncoder(os.Stdout).Encode(map[string]string{"error": "MMS_INVALID_PDU"})
|
||||
}
|
||||
}()
|
||||
var r Request
|
||||
if err := json.NewDecoder(io.LimitReader(os.Stdin, 2*limit)).Decode(&r); err != nil {
|
||||
json.NewEncoder(os.Stdout).Encode(map[string]string{"error": "MMS_INVALID_REQUEST"})
|
||||
return
|
||||
}
|
||||
value, err := run(r)
|
||||
if err != nil {
|
||||
value = map[string]string{"error": "MMS_CODEC_ERROR"}
|
||||
}
|
||||
json.NewEncoder(os.Stdout).Encode(value)
|
||||
}
|
||||
40
tundra/mms-codec/main_test.go
Normal file
40
tundra/mms-codec/main_test.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"gitlab.com/ubports/development/core/nuntium/mms"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncodePhotoAndText(t *testing.T) {
|
||||
result, err := run(Request{Operation: "encode", Recipients: []string{"+15550199"}, Parts: []Part{{"text.txt", "text/plain", []byte("MMS test é")}, {"photo.jpg", "image/jpeg", []byte{255, 216, 255, 217}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data := result.(map[string]any)["data"].([]byte)
|
||||
message := mms.NewMRetrieveConf("")
|
||||
message.Type = mms.TYPE_SEND_REQ
|
||||
if err = mms.NewDecoder(data).Decode(message); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(message.Attachments) != 2 || string(message.Attachments[0].Data) != "MMS test é" {
|
||||
t.Fatalf("lost parts: %d", len(message.Attachments))
|
||||
}
|
||||
}
|
||||
func TestSendRequiresMatchingConfirmation(t *testing.T) {
|
||||
// M-Send.conf: transaction 't', MMS 1.1, status OK.
|
||||
data, _ := base64.StdEncoding.DecodeString("jIGYdACNkZKA")
|
||||
value, err := run(Request{Operation: "response", Data: data, Transaction: "t"})
|
||||
if err != nil || !value.(map[string]any)["accepted"].(bool) {
|
||||
t.Fatal(value, err)
|
||||
}
|
||||
if _, err = run(Request{Operation: "response", Data: data, Transaction: "other"}); err == nil {
|
||||
t.Fatal("accepted unrelated response")
|
||||
}
|
||||
}
|
||||
func TestLimits(t *testing.T) {
|
||||
if _, err := run(Request{Operation: "retrieve", Data: make([]byte, limit+1)}); err == nil {
|
||||
t.Fatal("oversize accepted")
|
||||
}
|
||||
}
|
||||
29
tundra/runtime/audio-devices.py
Normal file
29
tundra/runtime/audio-devices.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Give the audio group access to Sargo's calibration and amplifier devices."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import time
|
||||
|
||||
DEVICES = ('msm_audio_cal', 'msm_rtac', 'msm_cirrus_playback')
|
||||
|
||||
|
||||
def prepare(root=Path('/dev')):
|
||||
for name in DEVICES:
|
||||
path = root / name
|
||||
if not stat.S_ISCHR(path.lstat().st_mode):
|
||||
raise RuntimeError('Not an audio character device: ' + name)
|
||||
os.chown(path, 0, 1005, follow_symlinks=False)
|
||||
path.chmod(0o660)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
for attempt in range(60):
|
||||
try:
|
||||
prepare()
|
||||
break
|
||||
except FileNotFoundError:
|
||||
if attempt == 59:
|
||||
raise
|
||||
time.sleep(1)
|
||||
323
tundra/runtime/bluetooth-control.py
Normal file
323
tundra/runtime/bluetooth-control.py
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""BlueZ broker. The persistent bus owns discovery and the pairing agent."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
SOCKET = Path('/run/tundra-bluetooth/control.sock')
|
||||
LIMIT = 65536
|
||||
|
||||
|
||||
def validate(value):
|
||||
if not isinstance(value, dict) or value.get('version') != 1:
|
||||
raise ValueError('BLUETOOTH_INVALID_REQUEST')
|
||||
op = value.get('operation')
|
||||
if op not in ('status', 'power', 'discoverable', 'rename', 'scan', 'stopScan',
|
||||
'pair', 'unpair', 'connect', 'disconnect', 'reply', 'cancelPair'):
|
||||
raise ValueError('BLUETOOTH_INVALID_REQUEST')
|
||||
if op in ('pair', 'unpair', 'connect', 'disconnect', 'cancelPair') and not re.fullmatch(
|
||||
r'(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}', str(value.get('address', ''))):
|
||||
raise ValueError('BLUETOOTH_INVALID_ADDRESS')
|
||||
if op in ('power', 'discoverable') and not isinstance(value.get('enabled'), bool):
|
||||
raise ValueError('BLUETOOTH_INVALID_REQUEST')
|
||||
if op == 'rename' and (not isinstance(value.get('name'), str) or
|
||||
not 1 <= len(value['name'].encode()) <= 248 or
|
||||
any(ord(c) < 32 for c in value['name'])):
|
||||
raise ValueError('BLUETOOTH_INVALID_NAME')
|
||||
if op == 'reply' and (not isinstance(value.get('accept'), bool) or
|
||||
not isinstance(value.get('id'), str) or len(value['id']) > 40):
|
||||
raise ValueError('BLUETOOTH_INVALID_REQUEST')
|
||||
return op
|
||||
|
||||
|
||||
def device_record(props):
|
||||
cod = int(props.get('Class', 0))
|
||||
return dict(address=str(props['Address']), name=str(props.get('Alias', props['Address'])),
|
||||
paired=bool(props.get('Paired')), connected=bool(props.get('Connected')),
|
||||
uuids=[str(u) for u in props.get('UUIDs', [])],
|
||||
cod=dict(majorDeviceClass=(cod >> 8) & 31, minorDeviceClass=(cod >> 2) & 63,
|
||||
majorServiceClass=(cod >> 13) & 2047))
|
||||
|
||||
|
||||
def serve():
|
||||
import dbus
|
||||
import dbus.service
|
||||
from dbus.mainloop.glib import DBusGMainLoop
|
||||
from gi.repository import GLib
|
||||
DBusGMainLoop(set_as_default=True)
|
||||
bus = dbus.SystemBus(private=True)
|
||||
pending = {}
|
||||
discovery = {'owned': False, 'expires': 0}
|
||||
|
||||
def interface(path, name):
|
||||
return dbus.Interface(bus.get_object('org.bluez', path), name)
|
||||
|
||||
def objects():
|
||||
return interface('/', 'org.freedesktop.DBus.ObjectManager').GetManagedObjects()
|
||||
|
||||
def adapter():
|
||||
for path, interfaces in objects().items():
|
||||
if 'org.bluez.Adapter1' in interfaces:
|
||||
return str(path), interfaces['org.bluez.Adapter1']
|
||||
raise ValueError('BLUETOOTH_UNAVAILABLE')
|
||||
|
||||
def snapshot():
|
||||
path, props = adapter()
|
||||
return dict(available=True, address=str(props['Address']), name=str(props.get('Alias', 'Vulpes OS')),
|
||||
enabled=bool(props['Powered']), discoverable=bool(props['Discoverable']),
|
||||
discovering=bool(props['Discovering']),
|
||||
devices=[device_record(i['org.bluez.Device1']) for p, i in objects().items()
|
||||
if 'org.bluez.Device1' in i and str(i['org.bluez.Device1']['Adapter']) == path],
|
||||
pairing=[p['info'] for p in pending.values()])
|
||||
|
||||
def remote(address):
|
||||
path, _ = adapter()
|
||||
for p, interfaces in objects().items():
|
||||
props = interfaces.get('org.bluez.Device1', {})
|
||||
if str(props.get('Address', '')).upper() == address.upper() and str(props.get('Adapter')) == path:
|
||||
return str(p), props
|
||||
raise ValueError('BLUETOOTH_DEVICE_GONE')
|
||||
|
||||
class Rejected(dbus.DBusException):
|
||||
_dbus_error_name = 'org.bluez.Error.Rejected'
|
||||
|
||||
def drop(identity, error=True):
|
||||
row = pending.pop(identity, None)
|
||||
if row and error and row['fail']:
|
||||
row['fail'](Rejected('Pairing rejected or expired'))
|
||||
return False
|
||||
|
||||
def prompt(path, method, ok=None, fail=None, passkey=''):
|
||||
# One original Gaia pairing panel at a time, never auto-accept a peer.
|
||||
for identity in list(pending):
|
||||
drop(identity)
|
||||
props = interface(path, 'org.freedesktop.DBus.Properties').GetAll('org.bluez.Device1')
|
||||
identity = str(uuid.uuid4())
|
||||
pending[identity] = dict(ok=ok, fail=fail, path=str(path),
|
||||
info=dict(id=identity, method=method, address=str(props['Address']),
|
||||
deviceName=str(props.get('Alias', props['Address'])), passkey=str(passkey)))
|
||||
GLib.timeout_add_seconds(90, drop, identity)
|
||||
|
||||
def check_sender(sender):
|
||||
if sender != bus.get_name_owner('org.bluez'):
|
||||
raise Rejected('Only BlueZ may request pairing')
|
||||
|
||||
def clear_pending():
|
||||
for identity in list(pending):drop(identity)
|
||||
|
||||
class Agent(dbus.service.Object):
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='ou', out_signature='', sender_keyword='sender', async_callbacks=('ok', 'fail'))
|
||||
def RequestConfirmation(self, device, passkey, ok, fail, sender=None):
|
||||
check_sender(sender)
|
||||
prompt(device, 'confirmation', ok, fail, f'{int(passkey):06d}')
|
||||
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='o', out_signature='s', sender_keyword='sender', async_callbacks=('ok', 'fail'))
|
||||
def RequestPinCode(self, device, ok, fail, sender=None):
|
||||
check_sender(sender)
|
||||
prompt(device, 'enterpincode', ok, fail)
|
||||
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='o', out_signature='u', sender_keyword='sender', async_callbacks=('ok', 'fail'))
|
||||
def RequestPasskey(self, device, ok, fail, sender=None):
|
||||
check_sender(sender)
|
||||
prompt(device, 'enterpasskey', ok, fail)
|
||||
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='o', out_signature='', sender_keyword='sender', async_callbacks=('ok', 'fail'))
|
||||
def RequestAuthorization(self, device, ok, fail, sender=None):
|
||||
check_sender(sender)
|
||||
prompt(device, 'consent', ok, fail)
|
||||
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='os', out_signature='', sender_keyword='sender', async_callbacks=('ok', 'fail'))
|
||||
def AuthorizeService(self, device, service, ok, fail, sender=None):
|
||||
check_sender(sender)
|
||||
props = interface(device, 'org.freedesktop.DBus.Properties').GetAll('org.bluez.Device1')
|
||||
if props.get('Trusted') and props.get('Paired'):
|
||||
ok()
|
||||
else:
|
||||
prompt(device, 'consent', ok, fail)
|
||||
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='ouq', out_signature='', sender_keyword='sender')
|
||||
def DisplayPasskey(self, device, passkey, entered, sender=None):
|
||||
check_sender(sender)
|
||||
prompt(device, 'displaypasskey', passkey=f'{int(passkey):06d}')
|
||||
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='os', out_signature='', sender_keyword='sender')
|
||||
def DisplayPinCode(self, device, pincode, sender=None):
|
||||
check_sender(sender)
|
||||
prompt(device, 'displaypasskey', passkey=pincode)
|
||||
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='', out_signature='', sender_keyword='sender')
|
||||
def Cancel(self, sender=None):
|
||||
check_sender(sender)
|
||||
for identity in list(pending):
|
||||
drop(identity)
|
||||
|
||||
@dbus.service.method('org.bluez.Agent1', in_signature='', out_signature='', sender_keyword='sender')
|
||||
def Release(self, sender=None):
|
||||
check_sender(sender)
|
||||
clear_pending()
|
||||
|
||||
agent = Agent(bus, '/org/vulpes/BluetoothAgent')
|
||||
manager = interface('/org/bluez', 'org.bluez.AgentManager1')
|
||||
manager.RegisterAgent('/org/vulpes/BluetoothAgent', 'KeyboardDisplay')
|
||||
manager.RequestDefaultAgent('/org/vulpes/BluetoothAgent')
|
||||
|
||||
def stop_scan():
|
||||
if discovery['owned']:
|
||||
try:
|
||||
path, _ = adapter()
|
||||
interface(path, 'org.bluez.Adapter1').StopDiscovery()
|
||||
finally:
|
||||
discovery['owned'] = False
|
||||
|
||||
def expire_scan():
|
||||
if discovery['owned'] and time.monotonic() >= discovery['expires']:
|
||||
try:
|
||||
stop_scan()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
GLib.timeout_add_seconds(5, expire_scan)
|
||||
|
||||
def dispatch(value, finish):
|
||||
try:
|
||||
op = validate(value)
|
||||
path, props = adapter()
|
||||
prop = interface(path, 'org.freedesktop.DBus.Properties')
|
||||
if op == 'power':
|
||||
if not value['enabled']:
|
||||
stop_scan()
|
||||
prop.Set('org.bluez.Adapter1', 'Powered', dbus.Boolean(value['enabled'], variant_level=1))
|
||||
if not value['enabled']:
|
||||
clear_pending()
|
||||
elif op == 'discoverable':
|
||||
prop.Set('org.bluez.Adapter1', 'Pairable', dbus.Boolean(value['enabled'], variant_level=1))
|
||||
prop.Set('org.bluez.Adapter1', 'Discoverable', dbus.Boolean(value['enabled'], variant_level=1))
|
||||
elif op == 'rename':
|
||||
prop.Set('org.bluez.Adapter1', 'Alias', dbus.String(value['name'], variant_level=1))
|
||||
elif op == 'scan':
|
||||
if not discovery['owned']:
|
||||
interface(path, 'org.bluez.Adapter1').StartDiscovery()
|
||||
discovery['owned'] = True
|
||||
discovery['expires'] = time.monotonic() + 60
|
||||
elif op == 'stopScan':
|
||||
stop_scan()
|
||||
elif op == 'reply':
|
||||
row = pending.get(value['id'])
|
||||
if row is None:
|
||||
raise ValueError('BLUETOOTH_PAIRING_EXPIRED')
|
||||
method = row['info']['method']
|
||||
code = value.get('code', '')
|
||||
if value['accept'] and method in ('enterpincode', 'enterpasskey'):
|
||||
valid = isinstance(code, str) and 1 <= len(code) <= 16 and code.isascii() and code.isprintable()
|
||||
if method == 'enterpasskey':
|
||||
valid = isinstance(code, str) and re.fullmatch(r'[0-9]{1,6}', code)
|
||||
if not valid:
|
||||
raise ValueError('BLUETOOTH_INVALID_PIN')
|
||||
pending.pop(value['id'])
|
||||
if not value['accept'] and row['fail']:
|
||||
row['fail'](Rejected('Rejected by user'))
|
||||
elif row['ok']:
|
||||
if method == 'enterpincode': row['ok'](dbus.String(code))
|
||||
elif method == 'enterpasskey': row['ok'](dbus.UInt32(int(code)))
|
||||
else: row['ok']()
|
||||
elif op in ('pair', 'unpair', 'connect', 'disconnect', 'cancelPair'):
|
||||
device, dp = remote(value['address'])
|
||||
obj = interface(device, 'org.bluez.Device1')
|
||||
if op == 'unpair':
|
||||
interface(path, 'org.bluez.Adapter1').RemoveDevice(dbus.ObjectPath(device))
|
||||
elif op == 'cancelPair':
|
||||
obj.CancelPairing()
|
||||
elif op in ('connect', 'disconnect'):
|
||||
if not dp.get('Paired'):
|
||||
raise ValueError('BLUETOOTH_NOT_PAIRED')
|
||||
getattr(obj, 'Connect' if op == 'connect' else 'Disconnect')(
|
||||
reply_handler=lambda: finish(True), error_handler=lambda e: finish(error(e)), timeout=60)
|
||||
return False
|
||||
else:
|
||||
stop_scan()
|
||||
def paired():
|
||||
# Trust follows an explicit successful Pair initiated from Gaia.
|
||||
try:
|
||||
interface(device, 'org.freedesktop.DBus.Properties').Set(
|
||||
'org.bluez.Device1', 'Trusted', dbus.Boolean(True, variant_level=1))
|
||||
for identity, row in list(pending.items()):
|
||||
if row['path']==device:drop(identity,False)
|
||||
finish(True)
|
||||
except Exception as exc:finish(error(exc))
|
||||
obj.Pair(reply_handler=paired, error_handler=lambda e: finish(error(e)), timeout=95)
|
||||
return False
|
||||
finish(snapshot())
|
||||
except Exception as e:
|
||||
finish(error(e))
|
||||
return False
|
||||
|
||||
def error(exc):
|
||||
name = str(exc).split(':', 1)[0]
|
||||
if hasattr(exc, 'get_dbus_name'):
|
||||
name = exc.get_dbus_name().rsplit('.', 1)[-1]
|
||||
return {'error': name if re.fullmatch(r'[A-Za-z_]+', name) else 'BLUETOOTH_FAILED'}
|
||||
|
||||
slots = threading.BoundedSemaphore(8)
|
||||
def connection(sock):
|
||||
try:
|
||||
with sock:
|
||||
sock.settimeout(110)
|
||||
data = b''
|
||||
while not data.endswith(b'\n') and len(data) <= 4096:
|
||||
chunk = sock.recv(4097-len(data))
|
||||
if not chunk: break
|
||||
data += chunk
|
||||
if len(data) > 4096: raise ValueError('BLUETOOTH_INVALID_REQUEST')
|
||||
value = json.loads(data)
|
||||
validate(value)
|
||||
done = threading.Event(); reply = []
|
||||
def finish(result):
|
||||
reply.append(result); done.set()
|
||||
GLib.idle_add(dispatch, value, finish)
|
||||
if not done.wait(100): raise ValueError('BLUETOOTH_TIMEOUT')
|
||||
result = reply[0]
|
||||
sock.sendall(json.dumps(result if isinstance(result, dict) and 'error' in result else {'result':result}).encode()+b'\n')
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
finally:
|
||||
slots.release()
|
||||
|
||||
def listen():
|
||||
import pwd
|
||||
SOCKET.parent.mkdir(parents=True, exist_ok=True)
|
||||
SOCKET.unlink(missing_ok=True)
|
||||
with socket.socket(socket.AF_UNIX) as server:
|
||||
server.bind(str(SOCKET)); os.chmod(SOCKET, 0o660)
|
||||
os.chown(SOCKET, 0, pwd.getpwnam('droidian').pw_gid)
|
||||
server.listen(8)
|
||||
while True:
|
||||
sock, _ = server.accept()
|
||||
if not slots.acquire(blocking=False): sock.close(); continue
|
||||
threading.Thread(target=connection, args=(sock,), daemon=True).start()
|
||||
threading.Thread(target=listen, daemon=True).start()
|
||||
GLib.MainLoop().run()
|
||||
|
||||
|
||||
def client():
|
||||
data = sys.stdin.read(4097)
|
||||
if len(data) > 4096: raise ValueError('BLUETOOTH_INVALID_REQUEST')
|
||||
validate(json.loads(data))
|
||||
with socket.socket(socket.AF_UNIX) as sock:
|
||||
sock.settimeout(110); sock.connect(str(SOCKET)); sock.sendall(data.encode()+b'\n')
|
||||
result = b''
|
||||
while not result.endswith(b'\n') and len(result) <= LIMIT:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk: break
|
||||
result += chunk
|
||||
if len(result) > LIMIT: raise ValueError('BLUETOOTH_INVALID_REPLY')
|
||||
print(result.decode())
|
||||
|
||||
if __name__ == '__main__':
|
||||
client() if '--client' in sys.argv else serve()
|
||||
|
|
@ -6,6 +6,7 @@ The parent owns stdin. EOF or five seconds without a request closes the sensor.
|
|||
A pbuffer and external texture are required even for the software NV21 callback.
|
||||
"""
|
||||
import argparse
|
||||
import fcntl
|
||||
import struct
|
||||
import ctypes as C
|
||||
import json
|
||||
|
|
@ -214,6 +215,12 @@ def main():
|
|||
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)
|
||||
try:
|
||||
# A preview packet is 450 KiB. A larger pipe avoids waking the parent
|
||||
# for each 64 KiB fragment; unsupported kernels retain their default.
|
||||
fcntl.fcntl(output.fileno(), fcntl.F_SETPIPE_SZ, 1024 * 1024)
|
||||
except OSError:
|
||||
pass
|
||||
os.dup2(2, 1) # HAL callbacks can print; keep stdout protocol uncontaminated.
|
||||
camera = None
|
||||
try:
|
||||
|
|
@ -236,8 +243,12 @@ def main():
|
|||
last_request = time.monotonic()
|
||||
if command == 'frame\n':
|
||||
data = camera.frame or b''
|
||||
output.write(struct.pack('<II', camera.count & 0xffffffff, len(data)))
|
||||
output.write(data)
|
||||
packet = memoryview(struct.pack('<II', camera.count & 0xffffffff, len(data)) + data)
|
||||
while packet:
|
||||
written = output.write(packet)
|
||||
if not written:
|
||||
raise BrokenPipeError('Camera preview pipe closed')
|
||||
packet = packet[written:]
|
||||
else:
|
||||
try:
|
||||
result = camera.control(json.loads(command))
|
||||
|
|
|
|||
3
tundra/runtime/clock-service.conf
Normal file
3
tundra/runtime/clock-service.conf
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[Service]
|
||||
StateDirectory=tundra/clock
|
||||
ReadWritePaths=/var/lib/tundra/clock
|
||||
|
|
@ -20,8 +20,10 @@ NETWORK_SERVICES = frozenset(('irsc_util', 'vendor.per_mgr', 'vendor.rmt_storage
|
|||
'vendor.tftp_server', 'cnss-daemon', 'pd_mapper'))
|
||||
|
||||
|
||||
HARDWARE_SERVICES = frozenset(('vendor.bluetooth-1-0', 'vendor.qcrild', 'vendor.google.radioext@1.0',
|
||||
'vendor.audio-hal-2-0', 'vendor.camera-provider-2-4',
|
||||
# PulseAudio opens the primary audio HAL through libhybris. A second Android
|
||||
# audio service competes for its ACDB calibration buffers on Sargo.
|
||||
HARDWARE_SERVICES = frozenset(('vendor.bluetooth-1-0', 'vendor.qcrild', 'netmgrd', 'netd-hal-1-1-stub', 'ipastart_sh', 'vendor.google.radioext@1.0',
|
||||
'vendor.camera-provider-2-4',
|
||||
'camera_service', 'minimedia', 'minisf', 'miniaf'))
|
||||
|
||||
def is_init_script(path):
|
||||
|
|
|
|||
3
tundra/runtime/hotspot-firewall.conf
Normal file
3
tundra/runtime/hotspot-firewall.conf
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Sargo's 4.9 kernel provides the legacy netfilter backend.
|
||||
[main]
|
||||
firewall-backend=iptables
|
||||
23
tundra/runtime/mms-codec.py
Normal file
23
tundra/runtime/mms-codec.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Build the separate GPL-3.0 Nuntium codec helper from locked Go modules."""
|
||||
import hashlib,json,os,shutil,subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def stage(root):
|
||||
project=Path(__file__).resolve().parents[2]
|
||||
source=project/'tundra/mms-codec'
|
||||
out=project/'tundra/out/mms-codec-build';out.mkdir(parents=True,exist_ok=True)
|
||||
binary=out/'tundra-mms-codec'
|
||||
env={**os.environ,'GOMODCACHE':str(project/'tundra/downloads/go-modules'),
|
||||
'GOCACHE':str(project/'tundra/out/go-cache'),'GOOS':'linux','GOARCH':'arm64','CGO_ENABLED':'0'}
|
||||
subprocess.run(['go','build','-mod=readonly','-trimpath','-ldflags=-s -w','-buildvcs=false','-o',str(binary),'.'],cwd=source,env=env,check=True)
|
||||
target=root/'usr/libexec/tundra-mms-codec';target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(binary,target)
|
||||
doc=root/'usr/share/doc/tundra-mms-codec';doc.mkdir(parents=True,exist_ok=True)
|
||||
for name in ['main.go','go.mod','go.sum','COPYING']:shutil.copy2(source/name,doc/name)
|
||||
(doc/'provenance.json').write_text(json.dumps({'license':'GPL-3.0-only',
|
||||
'source':'https://gitlab.com/ubports/development/core/nuntium',
|
||||
'revision':'d051757417a42872abb99c7583a8a4af6e3108f5',
|
||||
'sha256':hashlib.sha256(binary.read_bytes()).hexdigest()},indent=2)+'\n')
|
||||
return target
|
||||
326
tundra/runtime/mms-control.py
Normal file
326
tundra/runtime/mms-control.py
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Durable MMS queue, oFono bearer and WAP-push agent. No Wi-Fi fallback."""
|
||||
import base64
|
||||
import contextlib
|
||||
import hashlib
|
||||
import http.client
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from urllib.parse import urlsplit
|
||||
import uuid
|
||||
|
||||
ROOT = Path('/var/lib/tundra/mms')
|
||||
SOCKET = Path('/run/tundra-mms/mms.sock')
|
||||
CODEC = '/usr/libexec/tundra-mms-codec'
|
||||
LIMIT = 600 * 1024
|
||||
WIRE_LIMIT = 2 * LIMIT
|
||||
LOCK = threading.RLock()
|
||||
MODEM = '/ril_0'
|
||||
|
||||
|
||||
def save(path, data):
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
temp = path.with_suffix('.tmp')
|
||||
with open(temp, 'w', opener=lambda p, f: os.open(p, f, 0o600)) as stream:
|
||||
json.dump(data, stream); stream.flush(); os.fsync(stream.fileno())
|
||||
temp.replace(path)
|
||||
|
||||
|
||||
def records():
|
||||
path = ROOT/'queue.json'
|
||||
return json.loads(path.read_text()) if path.exists() else []
|
||||
|
||||
|
||||
def codec(operation, **data):
|
||||
result = subprocess.run([CODEC], input=json.dumps({'operation': operation, **data}),
|
||||
text=True, capture_output=True, timeout=10, env={**os.environ, 'GOMEMLIMIT': '64MiB'})
|
||||
if result.returncode: raise ValueError('MMS_CODEC_ERROR')
|
||||
value = json.loads(result.stdout)
|
||||
if 'error' in value: raise ValueError(value['error'])
|
||||
return value
|
||||
|
||||
|
||||
def validate_send(value):
|
||||
recipients = value.get('recipients')
|
||||
if not isinstance(recipients,list) or not 1 <= len(recipients) <= 10 or any(
|
||||
not isinstance(n,str) or not re.fullmatch(r'\+?[0-9]{3,20}',n) for n in recipients):
|
||||
raise ValueError('INVALID_NUMBER')
|
||||
parts = value.get('parts')
|
||||
if not isinstance(parts,list) or not 1 <= len(parts) <= 20: raise ValueError('INVALID_MMS')
|
||||
total = 0
|
||||
for part in parts:
|
||||
if not isinstance(part,dict): raise ValueError('INVALID_MMS')
|
||||
if not isinstance(part.get('id'),str) or not re.fullmatch(r'[\w .@+-]{1,100}',part['id']): raise ValueError('INVALID_MMS')
|
||||
if not isinstance(part.get('type'),str) or not re.fullmatch(r'[a-zA-Z0-9.+-]+/[a-zA-Z0-9.+-]+',part['type']): raise ValueError('INVALID_MMS')
|
||||
try: total += len(base64.b64decode(part['data'],validate=True))
|
||||
except (ValueError,TypeError,KeyError): raise ValueError('INVALID_MMS')
|
||||
if total > LIMIT: raise ValueError('MMS_TOO_LARGE')
|
||||
subject=value.get('subject','')
|
||||
if not isinstance(subject,str) or len(subject)>200: raise ValueError('INVALID_MMS')
|
||||
return {'recipients':recipients,'parts':parts,'subject':subject}
|
||||
|
||||
|
||||
def request(value):
|
||||
if not isinstance(value,dict) or value.get('version')!=1: raise ValueError('INVALID_REQUEST')
|
||||
op=value.get('operation')
|
||||
with LOCK:
|
||||
rows=records()
|
||||
if op=='send':
|
||||
message=validate_send(value)
|
||||
# Encode before accepting the queue entry so malformed content isn't queued.
|
||||
encoded=codec('encode',**message)
|
||||
identity=str(uuid.uuid4())
|
||||
rows.append({'id':identity,'direction':'outgoing','state':'pending','encoded':encoded,'created':time.time()})
|
||||
save(ROOT/'queue.json',rows)
|
||||
return {'path':identity,'state':'pending'}
|
||||
if op=='state':
|
||||
row=next((r for r in rows if r['id']==value.get('path')),None)
|
||||
return {k:row[k] for k in ['state','error'] if k in row} if row else {'state':'unknown'}
|
||||
if op=='incoming':
|
||||
return [r['message'] for r in rows if r['direction']=='incoming' and r['state']=='received'][:1]
|
||||
if op=='ack':
|
||||
ids=value.get('ids')
|
||||
if not isinstance(ids,list) or len(ids)>100: raise ValueError('INVALID_REQUEST')
|
||||
for row in rows:
|
||||
if row['direction']=='incoming' and row['id'] in ids and row['state']=='received':
|
||||
row['state']='stored';row.pop('message',None)
|
||||
save(ROOT/'queue.json',rows);return True
|
||||
raise ValueError('NOT_SUPPORTED')
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def bearer():
|
||||
import dbus
|
||||
bus=dbus.SystemBus(private=True)
|
||||
# This short-lived connection is closed after each transfer. With GLib,
|
||||
# the bus default would terminate the whole daemon on that disconnect.
|
||||
bus.set_exit_on_disconnect(False)
|
||||
try:
|
||||
conn=dbus.Interface(bus.get_object('org.ofono',MODEM),'org.ofono.ConnectionManager')
|
||||
contexts=[(str(p),v) for p,v in conn.GetContexts() if str(v.get('Type'))=='mms']
|
||||
if not contexts: raise ValueError('MMS_APN_MISSING')
|
||||
path,props=contexts[0]
|
||||
# Only provision an incomplete default context, never replace user APNs.
|
||||
if not props.get('MessageCenter'):
|
||||
sim=dbus.Interface(bus.get_object('org.ofono',MODEM),'org.ofono.SimManager').GetProperties()
|
||||
imsi=str(sim.get('SubscriberIdentity',''))
|
||||
if imsi.startswith('20801') and str(props.get('AccessPointName')) in ('','mms'):
|
||||
cfg={'AccessPointName':'orange.acte','Username':'orange','Password':'orange',
|
||||
'AuthenticationMethod':'pap','Protocol':'ip','MessageCenter':'http://mms.orange.fr',
|
||||
'MessageProxy':'192.168.10.200:8080'}
|
||||
ctx=dbus.Interface(bus.get_object('org.ofono',path),'org.ofono.ConnectionContext')
|
||||
for key,val in cfg.items():ctx.SetProperty(key,dbus.String(val,variant_level=1))
|
||||
props=ctx.GetProperties()
|
||||
else: raise ValueError('MMS_APN_MISSING')
|
||||
ctx=dbus.Interface(bus.get_object('org.ofono',path),'org.ofono.ConnectionContext')
|
||||
activated=False;manager=None;previous=None;cleanup=[]
|
||||
try:
|
||||
try:
|
||||
manager=dbus.Interface(bus.get_object('org.ofono','/'),'org.nemomobile.ofono.ModemManager')
|
||||
previous=manager.GetMmsSim()
|
||||
sim=dbus.Interface(bus.get_object('org.ofono',MODEM),'org.ofono.SimManager').GetProperties()
|
||||
manager.SetMmsSim(sim['SubscriberIdentity'])
|
||||
except dbus.DBusException:manager=None
|
||||
if not props.get('Active'):
|
||||
try:ctx.SetProperty('Active',dbus.Boolean(True,variant_level=1),timeout=45)
|
||||
except dbus.DBusException:raise ValueError('MMS_DATA_UNAVAILABLE')
|
||||
activated=True
|
||||
props=ctx.GetProperties();settings=props.get('Settings',{})
|
||||
interface=str(settings.get('Interface',''));address=str(settings.get('Address',''))
|
||||
if not re.fullmatch(r'[\w.-]{1,15}',interface) or not address:raise ValueError('MMS_DATA_UNAVAILABLE')
|
||||
ip=ipaddress.ip_address(address)
|
||||
if ip.version!=4:raise ValueError('MMS_DATA_UNAVAILABLE')
|
||||
proxy=str(props.get('MessageProxy',''));url=str(props.get('MessageCenter',''))
|
||||
parsed=urlsplit(url)
|
||||
if parsed.scheme!='http' or not parsed.hostname or parsed.username:raise ValueError('MMS_APN_MISSING')
|
||||
proxyurl=urlsplit(proxy if '://' in proxy else '//'+proxy)
|
||||
host=proxyurl.hostname or parsed.hostname;port=proxyurl.port or (8080 if proxy else parsed.port or 80)
|
||||
# Operator MMS profiles normally expose an IPv4 proxy. Avoid DNS via Wi-Fi.
|
||||
try: destination=str(ipaddress.IPv4Address(host))
|
||||
except ValueError:raise ValueError('MMS_PROXY_REQUIRED')
|
||||
subprocess.run(['ip','link','set',interface,'up'],check=True,stdout=subprocess.DEVNULL)
|
||||
prefix=ipaddress.IPv4Network('0.0.0.0/'+str(settings.get('Netmask','255.255.255.255'))).prefixlen
|
||||
current=json.loads(subprocess.check_output(['ip','-j','addr','show','dev',interface]))
|
||||
if not any(a.get('local')==address for link in current for a in link.get('addr_info',[])):
|
||||
address_args=[address+'/'+str(prefix),'dev',interface]
|
||||
subprocess.run(['ip','addr','add',*address_args],check=True,stdout=subprocess.DEVNULL)
|
||||
cleanup.append(['ip','addr','del',*address_args])
|
||||
routes=json.loads(subprocess.check_output(['ip','-j','route','show',destination+'/32']))
|
||||
if routes and not any(r.get('dev')==interface for r in routes):raise ValueError('MMS_ROUTE_CONFLICT')
|
||||
if not routes:
|
||||
route_args=[destination+'/32','dev',interface,'src',address]
|
||||
subprocess.run(['ip','route','add',*route_args],check=True,stdout=subprocess.DEVNULL)
|
||||
cleanup.append(['ip','route','del',*route_args])
|
||||
yield {'interface':interface,'address':address,'host':destination,'port':port,'url':url,'proxy':bool(proxy)}
|
||||
finally:
|
||||
for command in reversed(cleanup):
|
||||
subprocess.run(command,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
if activated:
|
||||
try:ctx.SetProperty('Active',dbus.Boolean(False,variant_level=1),timeout=20)
|
||||
except dbus.DBusException:pass
|
||||
if manager is not None and previous is not None:
|
||||
try:manager.SetMmsSim(previous)
|
||||
except dbus.DBusException:pass
|
||||
|
||||
finally:
|
||||
bus.close()
|
||||
|
||||
|
||||
def transfer(connection, url, body=None):
|
||||
parsed=urlsplit(url)
|
||||
if parsed.scheme!='http' or not parsed.hostname or parsed.username or parsed.fragment:raise ValueError('MMS_INVALID_URL')
|
||||
if not connection['proxy'] and parsed.hostname!=urlsplit(connection['url']).hostname:raise ValueError('MMS_INVALID_URL')
|
||||
client=http.client.HTTPConnection(connection['host'],connection['port'],timeout=35)
|
||||
sock=socket.socket(socket.AF_INET,socket.SOCK_STREAM);client.sock=sock
|
||||
target=url if connection['proxy'] else (parsed.path or '/')+('?' + parsed.query if parsed.query else '')
|
||||
try:
|
||||
sock.settimeout(35)
|
||||
sock.setsockopt(socket.SOL_SOCKET,socket.SO_BINDTODEVICE,connection['interface'].encode()+b'\0')
|
||||
sock.bind((connection['address'],0));sock.connect((connection['host'],connection['port']))
|
||||
client.request('POST' if body is not None else 'GET',target,body,
|
||||
{'Content-Type':'application/vnd.wap.mms-message','Accept':'application/vnd.wap.mms-message',
|
||||
'User-Agent':'VulpesOS/2.7 MMS/1.1','Host':parsed.netloc})
|
||||
response=client.getresponse();data=response.read(LIMIT+1)
|
||||
if response.status!=200:raise ValueError('MMS_HTTP_ERROR')
|
||||
if len(data)>LIMIT:raise ValueError('MMS_TOO_LARGE')
|
||||
return data
|
||||
finally:client.close()
|
||||
|
||||
|
||||
def process(row):
|
||||
with bearer() as connection:
|
||||
if row['direction']=='outgoing':
|
||||
encoded=row['encoded'];data=transfer(connection,connection['url'],base64.b64decode(encoded['data']))
|
||||
response=codec('response',data=base64.b64encode(data).decode(),transaction=encoded['transaction'])
|
||||
if not response['accepted']:raise ValueError('MMS_REJECTED')
|
||||
return {'state':'sent','encoded':None,'acceptedByMMSC':True}
|
||||
notification=row['notification']
|
||||
if row.get('ackPending'):
|
||||
ack=codec('ack',transaction=notification['transaction'])
|
||||
transfer(connection,connection['url'],base64.b64decode(ack['data']))
|
||||
return {'ackPending':False}
|
||||
if notification['expires'] and notification['expires']<time.time():raise ValueError('MMS_EXPIRED')
|
||||
data=transfer(connection,notification['url']);message=codec('retrieve',data=base64.b64encode(data).decode())
|
||||
message.update({'receipt':row['id'],'type':'mms'})
|
||||
# Persist before acknowledging download to the operator.
|
||||
with LOCK:
|
||||
rows=records();target=next(r for r in rows if r['id']==row['id']);target.update({'message':message,'state':'received','ackPending':True});save(ROOT/'queue.json',rows)
|
||||
ack=codec('ack',transaction=notification['transaction'])
|
||||
transfer(connection,connection['url'],base64.b64decode(ack['data']))
|
||||
return {'ackPending':False}
|
||||
|
||||
|
||||
def process_once():
|
||||
with LOCK:
|
||||
pending=next((r.copy() for r in records() if
|
||||
(r['state']=='pending' or r.get('ackPending')) and r.get('retryAt',0)<=time.time()),None)
|
||||
if not pending:return False
|
||||
if pending['direction']=='outgoing':
|
||||
rows=records();next(r for r in rows if r['id']==pending['id'])['state']='submitting';save(ROOT/'queue.json',rows)
|
||||
try:result=process(pending)
|
||||
except Exception as e:
|
||||
code=str(e) if re.fullmatch(r'MMS_[A-Z_]+',str(e)) else 'MMS_TRANSPORT_ERROR'
|
||||
result={'error':code}
|
||||
if pending['direction']=='outgoing':result['state']='failed'
|
||||
else:
|
||||
attempts=pending.get('attempts',0)+1
|
||||
result.update(attempts=attempts,retryAt=time.time()+min(3600,30*2**min(attempts,7)))
|
||||
if code=='MMS_EXPIRED':result.update(state='failed',ackPending=False)
|
||||
with LOCK:
|
||||
rows=records();row=next((r for r in rows if r['id']==pending['id']),None)
|
||||
if row:
|
||||
row.update(result)
|
||||
if 'error' not in result:row.pop('error',None);row.pop('retryAt',None)
|
||||
save(ROOT/'queue.json',rows)
|
||||
return True
|
||||
|
||||
|
||||
def worker():
|
||||
while True:
|
||||
if not process_once():time.sleep(1)
|
||||
|
||||
|
||||
def serve():
|
||||
SOCKET.parent.mkdir(parents=True,exist_ok=True);SOCKET.unlink(missing_ok=True)
|
||||
with socket.socket(socket.AF_UNIX) as server:
|
||||
server.bind(str(SOCKET));os.chmod(SOCKET,0o660)
|
||||
import pwd
|
||||
os.chown(SOCKET,0,pwd.getpwnam('droidian').pw_gid)
|
||||
server.listen(4)
|
||||
while True:
|
||||
conn,_=server.accept()
|
||||
with conn:
|
||||
conn.settimeout(15);data=b''
|
||||
try:
|
||||
while not data.endswith(b'\n') and len(data)<=WIRE_LIMIT:
|
||||
chunk=conn.recv(min(65536,WIRE_LIMIT+1-len(data)))
|
||||
if not chunk:break
|
||||
data+=chunk
|
||||
if len(data)>WIRE_LIMIT:raise ValueError('MMS_TOO_LARGE')
|
||||
result={'ok':True,'result':request(json.loads(data))}
|
||||
except Exception as e:
|
||||
code=str(e) if re.fullmatch(r'[A-Z_]+',str(e)) else 'INVALID_REQUEST'
|
||||
result={'ok':False,'error':code}
|
||||
try:conn.sendall(json.dumps(result).encode()+b'\n')
|
||||
except OSError:pass
|
||||
|
||||
|
||||
def agent():
|
||||
import dbus,dbus.service
|
||||
from dbus.mainloop.glib import DBusGMainLoop
|
||||
from gi.repository import GLib
|
||||
DBusGMainLoop(set_as_default=True);bus=dbus.SystemBus();path='/org/vulpes/MmsPush'
|
||||
class Push(dbus.service.Object):
|
||||
@dbus.service.method('org.ofono.PushNotificationAgent',in_signature='aya{sv}',out_signature='',sender_keyword='sender')
|
||||
def ReceiveNotification(self,data,info,sender=None):
|
||||
if sender!=bus.get_name_owner('org.ofono'):raise dbus.DBusException('Denied')
|
||||
try:notification=codec('notification',data=base64.b64encode(bytes(data)).decode())
|
||||
except Exception:return
|
||||
identity=hashlib.sha256((notification['transaction']+'\n'+notification['url']).encode()).hexdigest()
|
||||
with LOCK:
|
||||
rows=records()
|
||||
if not any(r['id']==identity for r in rows):
|
||||
rows.append({'id':identity,'direction':'incoming','state':'pending','notification':notification});save(ROOT/'queue.json',rows)
|
||||
@dbus.service.method('org.ofono.PushNotificationAgent',in_signature='',out_signature='',sender_keyword='sender')
|
||||
def Release(self,sender=None):
|
||||
if sender==bus.get_name_owner('org.ofono'):GLib.timeout_add_seconds(3,register)
|
||||
push=Push(bus,path)
|
||||
def register():
|
||||
try:dbus.Interface(bus.get_object('org.ofono',MODEM),'org.ofono.PushNotification').RegisterAgent(dbus.ObjectPath(path));return False
|
||||
except dbus.DBusException:return True
|
||||
if register():GLib.timeout_add_seconds(10,register)
|
||||
GLib.MainLoop().run()
|
||||
|
||||
|
||||
def client():
|
||||
raw=sys.stdin.read(WIRE_LIMIT+1)
|
||||
if len(raw)>WIRE_LIMIT:raise ValueError('MMS_TOO_LARGE')
|
||||
with socket.socket(socket.AF_UNIX) as sock:
|
||||
sock.settimeout(20);sock.connect(str(SOCKET));sock.sendall(raw.encode()+b'\n');data=b''
|
||||
while not data.endswith(b'\n') and len(data)<=WIRE_LIMIT:
|
||||
chunk=sock.recv(65536)
|
||||
if not chunk:break
|
||||
data+=chunk
|
||||
if len(data)>WIRE_LIMIT:raise ValueError('MMS_TOO_LARGE')
|
||||
print(data.decode())
|
||||
|
||||
if __name__=='__main__':
|
||||
if '--client' in sys.argv:client()
|
||||
else:
|
||||
os.umask(0o077);ROOT.mkdir(parents=True,exist_ok=True)
|
||||
rows=records()
|
||||
for row in rows:
|
||||
if row['state']=='submitting':row.update({'state':'failed','error':'MMS_DELIVERY_UNKNOWN'})
|
||||
save(ROOT/'queue.json',rows)
|
||||
threading.Thread(target=worker,daemon=True).start()
|
||||
threading.Thread(target=serve,daemon=True).start()
|
||||
agent()
|
||||
316
tundra/runtime/mobile-settings.py
Normal file
316
tundra/runtime/mobile-settings.py
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Settings-only oFono queries. Never return IMSI, PIN retries or device serials."""
|
||||
import subprocess
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
CLOCK_CONFIG = Path("/var/lib/tundra/clock/settings.json")
|
||||
|
||||
def clock_automatic():
|
||||
try:
|
||||
return json.loads(CLOCK_CONFIG.read_text())["automatic"] is True
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
|
||||
def save_clock_automatic(enabled):
|
||||
CLOCK_CONFIG.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = CLOCK_CONFIG.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps({"automatic": enabled}) + "\n")
|
||||
temporary.chmod(0o600)
|
||||
temporary.replace(CLOCK_CONFIG)
|
||||
|
||||
def network_time():
|
||||
result = subprocess.run(["/usr/bin/busctl", "--system", "--timeout=5", "--json=short", "call",
|
||||
"org.ofono", "/ril_0", "org.ofono.NetworkTime", "GetNetworkTime"], capture_output=True, text=True, timeout=7)
|
||||
if result.returncode:
|
||||
raise RuntimeError("NETWORK_TIME_UNAVAILABLE")
|
||||
values = {key: value.get("data") for key, value in json.loads(result.stdout)["data"][0].items()}
|
||||
utc, received = values.get("UTC"), values.get("Received")
|
||||
now = time.monotonic()
|
||||
if type(utc) is not int or type(received) is not int or not 0 <= received <= now:
|
||||
raise RuntimeError("NETWORK_TIME_UNAVAILABLE")
|
||||
seconds = utc + now - received
|
||||
if not 946684800 <= seconds <= 4102444800:
|
||||
raise RuntimeError("NETWORK_TIME_UNAVAILABLE")
|
||||
return seconds
|
||||
|
||||
def sync_network_clock():
|
||||
if not clock_automatic():
|
||||
return
|
||||
seconds = network_time()
|
||||
if abs(time.time() - seconds) > 2:
|
||||
# oFono timestamps use CLOCK_MONOTONIC, not wall time or boot time.
|
||||
time.clock_settime(time.CLOCK_REALTIME, seconds)
|
||||
|
||||
|
||||
|
||||
def properties(interface, timeout=5):
|
||||
result = subprocess.run(['/usr/bin/busctl', '--system', '--timeout='+str(timeout), '--json=short',
|
||||
'call', 'org.ofono', '/ril_0', 'org.ofono.' + interface,
|
||||
'GetProperties'], capture_output=True, text=True, timeout=timeout+2)
|
||||
if result.returncode:
|
||||
raise RuntimeError('RADIO_NOT_READY')
|
||||
return {key: value.get('data') for key, value in json.loads(result.stdout)['data'][0].items()}
|
||||
|
||||
|
||||
def request(value):
|
||||
if value.get('action') in ('calls','setCallWaiting','setCallerId','forwarding','setForwarding'):
|
||||
return supplementary_request(value)
|
||||
if value.get('action') in ('radioMode', 'setRadioMode', 'apns', 'editApn'):
|
||||
return connection_request(value)
|
||||
if set(value) - {'version', 'operation', 'action', 'enabled'}:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
action = value.get('action')
|
||||
if action not in ('deliveryReports','dataEnabled','roaming') and 'enabled' in value:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
if action in ('network', 'dataEnabled', 'roaming'):
|
||||
if action != 'network':
|
||||
if type(value.get('enabled')) is not bool:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
name = 'Powered' if action == 'dataEnabled' else 'RoamingAllowed'
|
||||
set_property('ConnectionManager', name, 'b', value['enabled'])
|
||||
data = properties('ConnectionManager')
|
||||
if action != 'network' and data.get(name) is not value['enabled']:
|
||||
raise RuntimeError('RADIO_REQUEST_FAILED')
|
||||
network = properties('NetworkRegistration')
|
||||
return {'enabled':data.get('Powered'), 'roaming':data.get('RoamingAllowed'),
|
||||
'attached':data.get('Attached'), 'status':network.get('Status'),
|
||||
'operator':network.get('Name'), 'technology':network.get('Technology'), 'mode':network.get('Mode')}
|
||||
if action == 'sim':
|
||||
sim = properties('SimManager')
|
||||
modem = properties('Modem')
|
||||
# The slot key stays private to Gaia Settings; subscriber identity is not needed.
|
||||
return {'radioEnabled':modem.get('Powered') is True and modem.get('Online') is True,
|
||||
'present': sim.get('Present') is True, 'id': sim.get('CardIdentifier'),
|
||||
'operator': sim.get('ServiceProviderName', ''), 'pin': sim.get('PinRequired'),
|
||||
'fixedDialing': sim.get('FixedDialing'), 'pinEnabled':'pin' in sim.get('LockedPins', []), 'numbers': sim.get('SubscriberNumbers', [])}
|
||||
if action in ('smsc', 'messages'):
|
||||
props = properties('MessageManager')
|
||||
return {'address': props.get('ServiceCenterAddress'), 'deliveryReports': props.get('UseDeliveryReports')}
|
||||
if action == 'deliveryReports':
|
||||
if type(value.get('enabled')) is not bool:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
result = subprocess.run(['/usr/bin/busctl', '--system', '--timeout=5', 'call',
|
||||
'org.ofono', '/ril_0', 'org.ofono.MessageManager', 'SetProperty',
|
||||
'sv', 'UseDeliveryReports', 'b', str(value['enabled']).lower()],
|
||||
capture_output=True, text=True, timeout=7)
|
||||
if result.returncode:
|
||||
raise RuntimeError('RADIO_REQUEST_FAILED')
|
||||
props = properties('MessageManager')
|
||||
if props.get('UseDeliveryReports') is not value['enabled']:
|
||||
raise RuntimeError('RADIO_REQUEST_FAILED')
|
||||
return {'deliveryReports': props['UseDeliveryReports']}
|
||||
raise ValueError('NOT_SUPPORTED')
|
||||
|
||||
|
||||
def set_property(interface, name, signature, value, timeout=5):
|
||||
result = subprocess.run(['/usr/bin/busctl', '--system', '--timeout='+str(timeout), 'call',
|
||||
'org.ofono', '/ril_0', 'org.ofono.' + interface,
|
||||
'SetProperty', 'sv', name, signature, str(value).lower() if signature == 'b' else value],
|
||||
capture_output=True, text=True, timeout=timeout+2)
|
||||
if result.returncode:
|
||||
raise RuntimeError('RADIO_REQUEST_FAILED')
|
||||
|
||||
|
||||
def device_request(value):
|
||||
"""Clock service only; callers cannot supply commands, paths or D-Bus members."""
|
||||
import math
|
||||
from pathlib import Path
|
||||
allowed = {'version', 'operation', 'action', 'timezone', 'milliseconds', 'enabled'}
|
||||
if set(value) - allowed:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
action = value.get('action')
|
||||
extras = {'setTimezone': {'timezone'}, 'setTime': {'milliseconds'}, 'clock': set(), 'setAutomatic': {'enabled'}}
|
||||
if action in extras and set(value) - {'version', 'operation', 'action'} - extras[action]:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
if action == 'setTimezone':
|
||||
zone = value.get('timezone')
|
||||
if not isinstance(zone, str) or zone.startswith('/') or '..' in zone.split('/') or len(zone) > 80:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
root = Path('/usr/share/zoneinfo').resolve()
|
||||
target = (root / zone).resolve()
|
||||
if not target.is_relative_to(root) or not target.is_file() or target.read_bytes()[:4] != b'TZif':
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
args = ['set-timezone', zone]
|
||||
elif action == 'setAutomatic':
|
||||
if type(value.get('enabled')) is not bool:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
save_clock_automatic(value['enabled'])
|
||||
if value['enabled']:
|
||||
try:
|
||||
sync_network_clock()
|
||||
except RuntimeError:
|
||||
pass # Keep the requested mode while waiting for the network.
|
||||
args = None
|
||||
elif action == 'setTime':
|
||||
ms = value.get('milliseconds')
|
||||
if type(ms) not in (int, float) or not math.isfinite(ms) or not 946684800000 <= ms <= 4102444800000:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
if clock_automatic():
|
||||
raise RuntimeError('AUTOMATIC_TIME_ENABLED')
|
||||
args = ['set-time', '@' + str(int(ms / 1000))]
|
||||
elif action == 'clock':
|
||||
args = None
|
||||
else:
|
||||
raise ValueError('NOT_SUPPORTED')
|
||||
if args:
|
||||
result = subprocess.run(['/usr/bin/timedatectl', *args], capture_output=True, text=True, timeout=10)
|
||||
if result.returncode:
|
||||
raise RuntimeError('CLOCK_REQUEST_FAILED')
|
||||
result = subprocess.run(['/usr/bin/timedatectl', 'show', '-p', 'Timezone', '-p', 'NTP', '-p', 'CanNTP'],
|
||||
capture_output=True, text=True, timeout=5)
|
||||
if result.returncode:
|
||||
raise RuntimeError('CLOCK_UNAVAILABLE')
|
||||
state = dict(line.split('=', 1) for line in result.stdout.splitlines() if '=' in line)
|
||||
if action == 'setTimezone' and state.get('Timezone') != value['timezone']:
|
||||
raise RuntimeError('CLOCK_REQUEST_FAILED')
|
||||
return {'timezone':state.get('Timezone'), 'automatic':clock_automatic(), 'automaticAvailable':True}
|
||||
|
||||
|
||||
# Only existing user-facing Internet/MMS contexts are editable. IMS is owned by
|
||||
# the telephony stack and must never be rewritten by the Settings form.
|
||||
APN_FIELDS = {'apn':'AccessPointName', 'user':'Username', 'password':'Password',
|
||||
'protocol':'Protocol', 'authtype':'AuthenticationMethod',
|
||||
'mmsc':'MessageCenter', 'mmsproxy':'MessageProxy'}
|
||||
|
||||
|
||||
def contexts():
|
||||
result = subprocess.run(['/usr/bin/busctl','--system','--timeout=5','--json=short',
|
||||
'call','org.ofono','/ril_0','org.ofono.ConnectionManager','GetContexts'],
|
||||
capture_output=True,text=True,timeout=7)
|
||||
if result.returncode:
|
||||
raise RuntimeError('RADIO_NOT_READY')
|
||||
return {path: {key:item['data'] for key,item in props.items()}
|
||||
for path,props in json.loads(result.stdout)['data'][0]
|
||||
if re.fullmatch(r'/ril_0/context[0-9]+',path)
|
||||
and props.get('Type',{}).get('data') in ('internet','mms')}
|
||||
|
||||
|
||||
def apn_view(path, props):
|
||||
fields={key:str(props.get(name,'')) for key,name in APN_FIELDS.items() if key!='password'}
|
||||
return {'id':path,'type':'default' if props['Type']=='internet' else 'mms',
|
||||
'active':props.get('Active') is True, 'hasPassword':bool(props.get('Password')),
|
||||
'fields':fields}
|
||||
|
||||
|
||||
def set_context(path, name, value):
|
||||
result = subprocess.run(['/usr/bin/busctl','--system','--timeout=3','call','org.ofono',path,
|
||||
'org.ofono.ConnectionContext','SetProperty','sv',name,'s',value],
|
||||
capture_output=True,text=True,timeout=4)
|
||||
if result.returncode:
|
||||
raise RuntimeError('APN_WRITE_FAILED')
|
||||
|
||||
|
||||
def connection_request(value):
|
||||
action=value.get('action')
|
||||
extras={'radioMode':set(),'setRadioMode':{'mode'},'apns':set(),'editApn':{'id','fields'}}
|
||||
if action not in extras or set(value)-{'version','operation','action'}-extras[action]:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
if action in ('radioMode','setRadioMode'):
|
||||
before=properties('RadioSettings')
|
||||
available=[mode for mode in before.get('AvailableTechnologies',[]) if mode in ('gsm','umts','lte')]
|
||||
if action=='setRadioMode':
|
||||
mode=value.get('mode')
|
||||
if mode not in ['any',*available]:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
if mode != before.get('TechnologyPreference'):
|
||||
set_property('RadioSettings','TechnologyPreference','s',mode)
|
||||
before=properties('RadioSettings')
|
||||
if before.get('TechnologyPreference')!=mode:
|
||||
raise RuntimeError('RADIO_REQUEST_FAILED')
|
||||
return {'mode':before.get('TechnologyPreference'),'available':['any',*available]}
|
||||
if action=='apns':
|
||||
return [apn_view(path,props) for path,props in contexts().items()]
|
||||
path=value.get('id'); fields=value.get('fields')
|
||||
if not isinstance(path,str) or not re.fullmatch(r'/ril_0/context[0-9]+',path):
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
if not isinstance(fields,dict) or not fields or set(fields)-set(APN_FIELDS):
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
for key,field in fields.items():
|
||||
if not isinstance(field,str) or len(field.encode())>512 or any(ord(c)<32 for c in field):
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
if key=='apn' and not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9.-]{0,99}',field):
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
if key=='protocol' and field not in ('ip','ipv6','dual'):
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
if key=='authtype' and field not in ('none','pap','chap','any'):
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
if key=='mmsc' and field:
|
||||
from urllib.parse import urlsplit
|
||||
url=urlsplit(field)
|
||||
if url.scheme not in ('http','https') or not url.hostname or url.username or url.password:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
if key=='mmsproxy' and field and not re.fullmatch(r'[A-Za-z0-9.\[\]:-]{1,255}',field):
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
before=contexts().get(path)
|
||||
if before is None:
|
||||
raise ValueError('APN_NOT_FOUND')
|
||||
if before['Type']!='mms' and any(k in fields for k in ('mmsc','mmsproxy')):
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
changes={APN_FIELDS[key]:field for key,field in fields.items() if before.get(APN_FIELDS[key],'')!=field}
|
||||
if not changes:
|
||||
return apn_view(path,before)
|
||||
if before.get('Active'):
|
||||
raise RuntimeError('APN_IN_USE')
|
||||
changed=[]
|
||||
try:
|
||||
for name,field in changes.items():
|
||||
# Include the attempted property: a D-Bus timeout may arrive after
|
||||
# the modem has already applied it.
|
||||
changed.append(name)
|
||||
set_context(path,name,field)
|
||||
after=contexts().get(path)
|
||||
if after is None or any(after.get(name,'')!=field for name,field in changes.items()):
|
||||
raise RuntimeError('APN_WRITE_FAILED')
|
||||
return apn_view(path,after)
|
||||
except (RuntimeError,OSError,subprocess.TimeoutExpired):
|
||||
failed=False
|
||||
for name in reversed(changed):
|
||||
try:set_context(path,name,str(before.get(name,'')))
|
||||
except (RuntimeError,OSError,subprocess.TimeoutExpired):failed=True
|
||||
try:restored=contexts().get(path)
|
||||
except (RuntimeError,OSError,subprocess.TimeoutExpired):restored=None
|
||||
if failed or restored is None or any(restored.get(name,'')!=before.get(name,'') for name in changed):
|
||||
raise RuntimeError('APN_ROLLBACK_FAILED') from None
|
||||
raise RuntimeError('APN_WRITE_FAILED') from None
|
||||
|
||||
|
||||
FORWARDING = {'unconditional':'VoiceUnconditional', 'busy':'VoiceBusy',
|
||||
'noReply':'VoiceNoReply', 'notReachable':'VoiceNotReachable'}
|
||||
|
||||
|
||||
def supplementary_request(value):
|
||||
action=value.get('action')
|
||||
extras={'calls':set(), 'setCallWaiting':{'enabled'}, 'setCallerId':{'mode'},
|
||||
'forwarding':set(), 'setForwarding':{'reason','enabled','number'}}
|
||||
if action not in extras or set(value)-{'version','operation','action'}-extras[action]:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
if action in ('calls','setCallWaiting','setCallerId'):
|
||||
name=None
|
||||
if action=='setCallWaiting':
|
||||
if type(value.get('enabled')) is not bool:raise ValueError('INVALID_REQUEST')
|
||||
name='VoiceCallWaiting'; desired='enabled' if value['enabled'] else 'disabled'
|
||||
elif action=='setCallerId':
|
||||
desired=value.get('mode')
|
||||
if desired not in ('default','enabled','disabled'):raise ValueError('INVALID_REQUEST')
|
||||
name='HideCallerId'
|
||||
if name:set_property('CallSettings',name,'s',desired,timeout=20)
|
||||
# Network supplementary-service queries take several seconds on this RIL.
|
||||
props=properties('CallSettings',timeout=20)
|
||||
if name and props.get(name)!=desired:raise RuntimeError('RADIO_READBACK_FAILED')
|
||||
return {key:props.get(key) for key in ('HideCallerId','VoiceCallWaiting')}
|
||||
if action=='setForwarding':
|
||||
name=FORWARDING.get(value.get('reason'))
|
||||
number=value.get('number','');enabled=value.get('enabled')
|
||||
if name is None or type(enabled) is not bool or not isinstance(number,str):raise ValueError('INVALID_REQUEST')
|
||||
if enabled and not re.fullmatch(r'\+?[0-9]{1,20}',number):raise ValueError('INVALID_REQUEST')
|
||||
if not enabled and number:raise ValueError('INVALID_REQUEST')
|
||||
desired=number if enabled else ''
|
||||
before=properties('CallForwarding',timeout=20)
|
||||
if name not in before:raise RuntimeError('RADIO_NOT_READY')
|
||||
if before[name]!=desired:set_property('CallForwarding',name,'s',desired,timeout=20)
|
||||
props=properties('CallForwarding',timeout=20)
|
||||
if action=='setForwarding' and props.get(name)!=desired:raise RuntimeError('RADIO_READBACK_FAILED')
|
||||
return {key:props.get(member) for key,member in FORWARDING.items()}
|
||||
|
|
@ -2,10 +2,34 @@
|
|||
"""Connect the Sargo Bluetooth HAL to BlueZ; pairing stays user initiated."""
|
||||
|
||||
def prepare(root, write):
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
shutil.copy2(Path(__file__).with_name('bluetooth-control.py'),root/'etc/tundra/bluetooth-control.py')
|
||||
write('etc/systemd/system/tundra-bluetooth.service', """[Unit]
|
||||
Description=Tundra Bluetooth pairing and discovery broker
|
||||
Requires=bluetooth.service
|
||||
After=bluetooth.service
|
||||
PartOf=bluetooth.service
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 /etc/tundra/bluetooth-control.py
|
||||
RuntimeDirectory=tundra-bluetooth
|
||||
RuntimeDirectoryMode=0755
|
||||
UMask=0077
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
""")
|
||||
|
||||
write('etc/dbus-1/system.d/tundra-bluetooth.conf', '''<!DOCTYPE busconfig PUBLIC
|
||||
"-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
|
||||
<busconfig><policy user="root"><allow own="org.bluez"/><allow send_destination="org.bluez"/></policy></busconfig>
|
||||
<busconfig>
|
||||
<policy user="root"><allow own="org.bluez"/><allow send_destination="org.bluez"/></policy>
|
||||
<policy user="droidian"><allow send_destination="org.bluez"/></policy>
|
||||
</busconfig>
|
||||
''')
|
||||
write('etc/bluetooth/main.conf', '[General]\nName=Vulpes OS\nDiscoverableTimeout=120\n[Policy]\nAutoEnable=false\n')
|
||||
write('etc/systemd/system/tundra-bluebinder.service', '''[Unit]
|
||||
|
|
@ -31,4 +55,5 @@ After=tundra-bluebinder.service dbus.service
|
|||
''')
|
||||
wants = root/'etc/systemd/system/tundra.target.wants'
|
||||
wants.mkdir(parents=True, exist_ok=True)
|
||||
(wants/'tundra-bluetooth.service').symlink_to('/etc/systemd/system/tundra-bluetooth.service')
|
||||
(wants/'bluetooth.service').symlink_to('/usr/lib/systemd/system/bluetooth.service')
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ def prepare(root, write):
|
|||
write('etc/tundra/hardware-enabled', 'sargo\n')
|
||||
runpy.run_path(str(Path(__file__).with_name('runtime-packages.py')))['stage'](root)
|
||||
runpy.run_path(str(Path(__file__).with_name('prepare-bluetooth.py')))['prepare'](root, write)
|
||||
shutil.copy2(Path(__file__).with_name('audio-devices.py'), root/'etc/tundra/audio-devices.py')
|
||||
write('etc/udev/rules.d/99-tundra-audio.rules', '\n'.join(
|
||||
'KERNEL=="'+name+'", GROUP="1005", MODE="0660"'
|
||||
for name in ('msm_audio_cal', 'msm_rtac', 'msm_cirrus_playback'))+'\n')
|
||||
shutil.copy2(Path(__file__).with_name('modem-recovery.py'),
|
||||
root/'etc/tundra/modem-recovery.py')
|
||||
write('etc/systemd/system/tundra-hal.service.d/modem-recovery.conf', '''[Service]
|
||||
|
|
@ -60,14 +64,39 @@ StateDirectory=tundra/sms
|
|||
StateDirectoryMode=0700
|
||||
ReadWritePaths=/run/tundra-radio /var/lib/tundra/sms
|
||||
''')
|
||||
write('etc/pulse/client.conf', 'autospawn = no\n')
|
||||
write('etc/systemd/system/tundra-radio.service.d/clock.conf', Path(__file__).with_name('clock-service.conf').read_text())
|
||||
runpy.run_path(str(Path(__file__).with_name('mms-codec.py')))['stage'](root)
|
||||
shutil.copy2(Path(__file__).with_name('mms-control.py'),root/'etc/tundra/mms-control.py')
|
||||
write('etc/systemd/system/tundra-mms.service', """[Unit]
|
||||
Description=Tundra MMS queue and oFono push agent
|
||||
Requires=ofono.service
|
||||
After=ofono.service
|
||||
[Service]
|
||||
ExecStart=/usr/bin/python3 /etc/tundra/mms-control.py
|
||||
UMask=0077
|
||||
RuntimeDirectory=tundra-mms
|
||||
RuntimeDirectoryMode=0755
|
||||
StateDirectory=tundra/mms
|
||||
StateDirectoryMode=0700
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
ReadWritePaths=/var/lib/tundra/mms /run/tundra-mms
|
||||
MemoryMax=256M
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
""")
|
||||
write('etc/pulse/client.conf' , 'autospawn = no\n')
|
||||
write('etc/pulse/daemon.conf', 'exit-idle-time = -1\ndefault-sample-rate = 48000\n')
|
||||
write('etc/pulse/default.pa', '''.fail
|
||||
load-module module-native-protocol-unix
|
||||
load-module module-dbus-protocol
|
||||
load-module module-droid-card rate=48000 voice_virtual_stream=true
|
||||
load-module module-droid-hidl
|
||||
load-module module-default-device-restore
|
||||
load-module module-suspend-on-idle
|
||||
load-module module-bluetooth-policy auto_switch=0
|
||||
load-module module-bluetooth-discover headset=native
|
||||
''')
|
||||
# Prefer AAL for the next camera integration tests. The GStreamer probe
|
||||
# requests libstagefright_ccodec.so, absent from this Android 9 image.
|
||||
|
|
@ -77,11 +106,12 @@ load-module module-suspend-on-idle
|
|||
write('etc/systemd/system/tundra-audio.service', '''[Unit]
|
||||
Description=Tundra Sargo PulseAudio HAL bridge
|
||||
Requires=tundra-hal.service systemd-logind.service
|
||||
After=tundra-hal.service systemd-logind.service
|
||||
After=tundra-hal.service systemd-logind.service bluetooth.service
|
||||
[Service]
|
||||
User=droidian
|
||||
PAMName=tundra
|
||||
Environment=XDG_RUNTIME_DIR=/run/user/32011
|
||||
ExecStartPre=+/usr/bin/python3 /etc/tundra/audio-devices.py
|
||||
ExecStart=/usr/bin/pulseaudio -n --daemonize=no --exit-idle-time=-1 --log-target=journal --file=/etc/pulse/default.pa
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
|
@ -95,8 +125,10 @@ ExecStart=/usr/bin/python3 /etc/tundra/call-audio.py
|
|||
RuntimeDirectory=tundra-call-audio
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
ProtectHome=yes
|
||||
ProtectHome=tmpfs
|
||||
ProtectSystem=strict
|
||||
# The audio socket lives under /run/user, which ProtectHome also hides.
|
||||
BindReadOnlyPaths=/run/user/32011
|
||||
ReadWritePaths=/run/tundra-call-audio
|
||||
''')
|
||||
(root/'var/lib/tundra/sms').mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||
|
|
@ -108,5 +140,5 @@ ReadWritePaths=/run/tundra-call-audio
|
|||
'StateDirectory=tundra/sms','StateDirectoryMode=0700',
|
||||
'ReadWritePaths=/var/lib/tundra/sms','Restart=on-failure','RestartSec=3','']))
|
||||
wants = root/'etc/systemd/system/tundra.target.wants'
|
||||
for name in ('ofono', 'tundra-audio', 'ModemManager', 'tundra-radio', 'tundra-call-audio', 'tundra-sms'):
|
||||
for name in ('ofono', 'tundra-audio', 'ModemManager', 'tundra-radio', 'tundra-call-audio', 'tundra-sms', 'tundra-mms'):
|
||||
(wants/(name+'.service')).symlink_to('../'+name+'.service')
|
||||
|
|
|
|||
|
|
@ -118,9 +118,11 @@ for _ in range(120):
|
|||
time.sleep(.5)
|
||||
else:raise RuntimeError('Display HAL did not start')
|
||||
''')
|
||||
write('etc/tundra/lxc.conf','''lxc.rootfs.path = /android
|
||||
# Cellular HAL and oFono must see the same rmnet interfaces. Display-only
|
||||
# diagnostics retain a separate network namespace. No Android network manager runs.
|
||||
write('etc/tundra/lxc.conf',f'''lxc.rootfs.path = /android
|
||||
lxc.uts.name = tundra-hal
|
||||
lxc.net.0.type = empty
|
||||
lxc.net.0.type = {'none' if hardware else 'empty'}
|
||||
lxc.arch = aarch64
|
||||
lxc.init.cmd = /init
|
||||
lxc.apparmor.profile = unconfined
|
||||
|
|
@ -137,7 +139,7 @@ lxc.mount.entry = sys sys sysfs nodev,noexec,nosuid 0 0
|
|||
lxc.mount.entry = /android/data data none bind 0 0
|
||||
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','sms-inbox.py','call-audio.py','camera-preview.py','buttons.py'):
|
||||
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)
|
||||
write('etc/systemd/system/tundra-hardware.service', '''[Unit]
|
||||
Description=Tundra read-only battery and network state
|
||||
|
|
@ -172,6 +174,10 @@ NoNewPrivileges=yes
|
|||
(wants/'tundra-buttons.service').symlink_to('../tundra-buttons.service')
|
||||
# Older immutable images may not contain the networking packages yet.
|
||||
if network:
|
||||
write('etc/NetworkManager/conf.d/80-vulpes-hotspot.conf',Path(__file__).with_name('hotspot-firewall.conf').read_text())
|
||||
for name in ('iptables','ip6tables'):
|
||||
alias=root/'usr/sbin'/name
|
||||
if not alias.exists() and not alias.is_symlink():alias.symlink_to(name+'-legacy')
|
||||
write('etc/tundra/network-enabled', 'sargo\n')
|
||||
display = root/'etc/systemd/system/tundra-display.service'
|
||||
display.write_text(display.read_text().replace('Environment=VULPES_TUNDRA=1',
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import socket
|
|||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
SOCKET = Path('/run/tundra-radio/radio.sock')
|
||||
MODEM = '/ril_0'
|
||||
|
|
@ -54,12 +56,20 @@ def request(value):
|
|||
if not isinstance(value, dict) or value.get('version') != 1:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
operation = value.get('operation')
|
||||
if operation == 'deviceSettings':
|
||||
spec = importlib.util.spec_from_file_location('mobile_settings', Path(__file__).with_name('mobile-settings.py'))
|
||||
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
|
||||
return module.device_request(value)
|
||||
if operation == 'mobileSettings':
|
||||
spec = importlib.util.spec_from_file_location('mobile_settings',Path(__file__).with_name('mobile-settings.py'))
|
||||
module = importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
|
||||
return module.request(value)
|
||||
if operation in ('incomingMessages','ackMessages','messageState'):
|
||||
spec = importlib.util.spec_from_file_location('inbox',Path(__file__).with_name('sms-inbox.py'))
|
||||
inbox = importlib.util.module_from_spec(spec);spec.loader.exec_module(inbox)
|
||||
if operation == 'messageState':
|
||||
path = value.get('path', '')
|
||||
if not isinstance(path,str) or not re.fullmatch(r'/ril_0/message[0-9a-f_]+',path):
|
||||
if not isinstance(path,str) or not re.fullmatch(r'/ril_0/message[0-9a-fA-F_]+',path):
|
||||
raise ValueError('INVALID_SMS_PATH')
|
||||
state = inbox.outgoing_state(path)
|
||||
if state is None:
|
||||
|
|
@ -119,9 +129,100 @@ def request(value):
|
|||
raise ValueError('NOT_SUPPORTED')
|
||||
|
||||
|
||||
def reply(stream, value):
|
||||
stream.write((json.dumps(value, separators=(',', ':')) + '\n').encode())
|
||||
stream.flush()
|
||||
def send_response(conn, response):
|
||||
try:
|
||||
conn.sendall((json.dumps(response, separators=(',', ':')) + '\n').encode())
|
||||
except OSError:
|
||||
# Closing an app must not bring down the broker or retry an operation.
|
||||
pass
|
||||
|
||||
|
||||
def error_response(error):
|
||||
# TimeoutExpired includes command arguments, potentially an SMS body.
|
||||
reason = 'RADIO_TIMEOUT' if isinstance(error, subprocess.TimeoutExpired) else (
|
||||
'RADIO_ERROR' if isinstance(error, OSError) else str(error) or 'RADIO_ERROR')
|
||||
return {'ok': False, 'error': reason}
|
||||
|
||||
|
||||
def execute_request(conn, value):
|
||||
try:
|
||||
response = {'ok': True, 'result': request(value)}
|
||||
except (ValueError, RuntimeError, OSError, subprocess.TimeoutExpired) as error:
|
||||
response = error_response(error)
|
||||
send_response(conn, response)
|
||||
|
||||
|
||||
class SettingsWorker:
|
||||
"""Serialize Settings transactions without blocking the voice/SMS broker."""
|
||||
def __init__(self):
|
||||
self.pool = ThreadPoolExecutor(max_workers=1, thread_name_prefix='settings')
|
||||
self.slots = threading.BoundedSemaphore(8)
|
||||
|
||||
def submit(self, conn, value):
|
||||
if not self.slots.acquire(blocking=False):
|
||||
return False
|
||||
try:
|
||||
self.pool.submit(self.run, conn, value, time.monotonic())
|
||||
except RuntimeError:
|
||||
self.slots.release()
|
||||
return False
|
||||
return True
|
||||
|
||||
def run(self, conn, value, received):
|
||||
try:
|
||||
with conn:
|
||||
# Do not apply a stale preference after its queued client has
|
||||
# timed out. An already-started APN rollback still runs to completion.
|
||||
if time.monotonic() - received > 15:
|
||||
send_response(conn, {'ok':False, 'error':'RADIO_BUSY'})
|
||||
else:
|
||||
execute_request(conn, value)
|
||||
finally:
|
||||
self.slots.release()
|
||||
|
||||
def close(self):
|
||||
self.pool.shutdown(wait=True)
|
||||
|
||||
|
||||
def handle_connection(conn, settings_worker=None):
|
||||
conn.settimeout(20)
|
||||
line = b''
|
||||
try:
|
||||
while not line.endswith(b'\n') and len(line) <= 8192:
|
||||
chunk = conn.recv(min(1024, 8193 - len(line)))
|
||||
if not chunk:
|
||||
return False
|
||||
line += chunk
|
||||
if len(line) > 8192:
|
||||
raise ValueError('INVALID_REQUEST')
|
||||
value = json.loads(line.decode())
|
||||
except (socket.timeout, ConnectionError):
|
||||
return False
|
||||
except (ValueError, OSError) as error:
|
||||
send_response(conn, error_response(error))
|
||||
return False
|
||||
if settings_worker and isinstance(value, dict) and value.get('operation') in ('mobileSettings','deviceSettings'):
|
||||
if settings_worker.submit(conn, value):
|
||||
return True # The worker now owns and closes this connection.
|
||||
send_response(conn, {'ok':False, 'error':'RADIO_BUSY'})
|
||||
else:
|
||||
execute_request(conn, value)
|
||||
return False
|
||||
|
||||
|
||||
def network_clock_loop(stop, worker):
|
||||
# Use the same executor as manual date/time writes: disabling automatic
|
||||
# time cannot race a delayed network update. Voice/SMS keep their own loop.
|
||||
spec = importlib.util.spec_from_file_location('clock_settings', Path(__file__).with_name('mobile-settings.py'))
|
||||
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
|
||||
def tick():
|
||||
try:
|
||||
module.sync_network_clock()
|
||||
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired):
|
||||
pass # No SIM or no NITZ yet; retry after registration.
|
||||
while not stop.is_set():
|
||||
worker.pool.submit(tick)
|
||||
stop.wait(60)
|
||||
|
||||
|
||||
def serve():
|
||||
|
|
@ -139,27 +240,28 @@ def serve():
|
|||
except (KeyError, OSError):
|
||||
pass
|
||||
server.listen(8)
|
||||
while True:
|
||||
conn, _ = server.accept()
|
||||
with conn:
|
||||
conn.settimeout(20)
|
||||
line = b''
|
||||
while not line.endswith(b'\n') and len(line) < 8192:
|
||||
chunk = conn.recv(1024)
|
||||
if not chunk:
|
||||
break
|
||||
line += chunk
|
||||
try:
|
||||
result = request(json.loads(line.decode()))
|
||||
reply(conn.makefile('wb'), {'ok': True, 'result': result})
|
||||
except (ValueError, RuntimeError, OSError, subprocess.TimeoutExpired) as error:
|
||||
reply(conn.makefile('wb'), {'ok': False, 'error': str(error) or 'RADIO_ERROR'})
|
||||
worker = SettingsWorker()
|
||||
clock_stop = threading.Event()
|
||||
clock_thread = threading.Thread(target=network_clock_loop, args=(clock_stop, worker), daemon=True)
|
||||
clock_thread.start()
|
||||
try:
|
||||
while True:
|
||||
conn, _ = server.accept()
|
||||
if not handle_connection(conn, worker):
|
||||
conn.close()
|
||||
finally:
|
||||
clock_stop.set()
|
||||
clock_thread.join()
|
||||
worker.close()
|
||||
|
||||
|
||||
def client(envelope=False):
|
||||
raw = sys.stdin.read(8192)
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(20)
|
||||
value = json.loads(raw)
|
||||
# Seven bounded writes plus their rollback/readback can exceed 20 s.
|
||||
slow_settings = isinstance(value, dict) and value.get('operation') == 'mobileSettings' and value.get('action') in ('editApn','calls','setCallWaiting','setCallerId','forwarding','setForwarding')
|
||||
sock.settimeout(100 if slow_settings else 20)
|
||||
sock.connect(str(SOCKET))
|
||||
sock.sendall(raw.encode() + (b'' if raw.endswith('\n') else b'\n'))
|
||||
data = b''
|
||||
|
|
|
|||
|
|
@ -12,10 +12,19 @@ import tempfile
|
|||
import urllib.request
|
||||
|
||||
PACKAGES = (
|
||||
('pulseaudio-module-bluetooth_14.2-2+droidian4+git20250504201345.dc4fdb8.next.production_arm64.deb',
|
||||
'https://production.repo.droidian.org/pool/main/p/pulseaudio',
|
||||
'f0a6fa15835f1e22a5708dc46356bd30bb492d57655f769aaf4df78b6838f4f3'),
|
||||
('libsbc1_2.1-1+b2_arm64.deb', 's/sbc',
|
||||
'62dd7210ff6500e3ee4976d865826d603127787f588c4c4a8f93556104c95634'),
|
||||
('libdw1t64_0.196-1_arm64.deb', 'e/elfutils', 'a82634454de2b28d73374f693e4ab6c1c2634fa53b0139312c5f439c294dad2b'),
|
||||
('libelf1t64_0.196-1_arm64.deb', 'e/elfutils', 'ead8a1244824d982ce51fb82be4904312d5dc44ddc157e589def58c257738476'),
|
||||
('bluez_5.87-4_arm64.deb', 'b/bluez', 'fe733034dbdfa6146fc94112ad8ca0fce71d8e01d22a59ddb49ef2df3777d6b5'),
|
||||
('ca-certificates_20260816_all.deb', 'c/ca-certificates', '1cb8b73c74c47677b329fad4095fb9370227d4b8f1969372411478a4586c46cf'),
|
||||
('mobile-broadband-provider-info_20251101-1_all.deb', 'm/mobile-broadband-provider-info', 'c34e6bad952a320078fa91faf29a6c754d57963e7280998a01ccb058e60b87cc'),
|
||||
('audiosystem-passthrough_1.3.0-1+droidian1+git20240519112654.f49813c.next.production_arm64.deb',
|
||||
'https://production.repo.droidian.org/pool/main/a/audiosystem-passthrough',
|
||||
'bb4dc41ee66b3b00e8fbd3e0bb8559ab0b98675836219efef7b02958c47b815d'),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -27,7 +36,8 @@ def stage(root, cache=None):
|
|||
cache.mkdir(parents=True, exist_ok=True)
|
||||
records = []
|
||||
for name, directory, sha in PACKAGES:
|
||||
url = 'https://deb.debian.org/debian/pool/main/'+directory+'/'+name
|
||||
base = directory if directory.startswith('https://') else 'https://deb.debian.org/debian/pool/main/'+directory
|
||||
url = base+'/'+name
|
||||
package = cache/name
|
||||
if not package.exists():
|
||||
with urllib.request.urlopen(url, timeout=60) as response:
|
||||
|
|
@ -59,13 +69,28 @@ def stage(root, cache=None):
|
|||
selected = [source/'usr/share/doc/ca-certificates/copyright']
|
||||
else:
|
||||
selected = [p for p in (source/'usr').rglob('*') if p.is_file() or p.is_symlink()]
|
||||
# The packet-capture utility is not used by the runtime. Keep it in
|
||||
# the pinned package cache rather than enlarging the boot ramdisk.
|
||||
excluded = ['usr/bin/btmon'] if name.startswith('bluez_') else []
|
||||
selected = [p for p in selected if str(p.relative_to(source)) not in excluded]
|
||||
for path in selected:
|
||||
target = root/path.relative_to(source)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.is_symlink():
|
||||
if target.exists() or target.is_symlink():
|
||||
if not target.is_symlink() or target.readlink() != path.readlink():
|
||||
if not target.is_symlink():
|
||||
raise ValueError('Conflicting package link: '+str(target))
|
||||
# The reference runtime ships the compatibility link
|
||||
# for libelf 0.194. The pinned 0.196 package keeps the
|
||||
# same ABI name but points it at the newer SONAME.
|
||||
# Replace that one known compatibility link while
|
||||
# retaining the strict conflict checks for everything
|
||||
# else.
|
||||
if target.readlink() != path.readlink():
|
||||
if str(target.relative_to(root)) != 'usr/lib/aarch64-linux-gnu/libelf.so.1':
|
||||
raise ValueError('Conflicting package link: '+str(target))
|
||||
target.unlink()
|
||||
target.symlink_to(path.readlink())
|
||||
else:
|
||||
target.symlink_to(path.readlink())
|
||||
else:
|
||||
|
|
@ -75,7 +100,7 @@ def stage(root, cache=None):
|
|||
raise ValueError('Conflicting package file: '+str(target))
|
||||
shutil.copyfile(path, target)
|
||||
target.chmod(path.stat().st_mode & 0o777)
|
||||
records.append({'file': name, 'url': url, 'sha256': sha})
|
||||
records.append({'file': name, 'url': url, 'sha256': sha, 'excluded': excluded})
|
||||
record = root/'usr/share/vulpes/runtime-packages.json'
|
||||
record.parent.mkdir(parents=True, exist_ok=True)
|
||||
record.write_text(json.dumps(records, indent=2)+'\n')
|
||||
|
|
|
|||
|
|
@ -86,6 +86,10 @@ def main():
|
|||
if not p.is_file() or p.is_symlink() or not is_init_script(p):continue
|
||||
text=p.read_text();filtered,record=filter_rc(text);record['sourceSha256']=hashlib.sha256(text.encode()).hexdigest()
|
||||
p.write_text(filtered);report[str(p)]=record
|
||||
if Path('/etc/tundra/hardware-enabled').is_file():
|
||||
# The stock boot-completed trigger never runs without Android framework.
|
||||
with Path('/android/init.rc').open('a') as init:
|
||||
init.write('\non boot\n start ipastart_sh\n')
|
||||
(Path('/run/tundra')/'hal-policy-report.json').write_text(json.dumps(report,indent=2))
|
||||
# The container cannot open block devices. Its data and metadata live in RAM.
|
||||
for p in ('/android/data','/android/metadata'):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Narrow native Wi-Fi bridge. Credentials travel over a private Unix socket."""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import threading
|
||||
import argparse
|
||||
import configparser
|
||||
import json
|
||||
|
|
@ -17,6 +19,7 @@ SOCKET = '/run/tundra-wifi/control.sock'
|
|||
MAX_REQUEST = 16384
|
||||
DEVICE = 'wlan0'
|
||||
PROFILE_DIR = Path('/etc/NetworkManager/system-connections')
|
||||
MUTATION_LOCK = threading.Lock()
|
||||
|
||||
|
||||
class WifiError(Exception):
|
||||
|
|
@ -82,6 +85,29 @@ def networks(rescan=False):
|
|||
return sorted(found, key=lambda row: -row['signal'])[:128]
|
||||
|
||||
|
||||
def connection_info():
|
||||
info = {}
|
||||
address = nm('-g','IP4.ADDRESS','device','show',DEVICE,timeout=5)
|
||||
if address.returncode == 0:
|
||||
import ipaddress
|
||||
for line in address.stdout.splitlines():
|
||||
try:info['ipAddress'] = str(ipaddress.ip_interface(line).ip);break
|
||||
except ValueError:continue
|
||||
device = nm('-g','GENERAL.DBUS-PATH','device','show',DEVICE,timeout=5)
|
||||
path=device.stdout.strip() if device.returncode==0 else ''
|
||||
if re.fullmatch(r'/org/freedesktop/NetworkManager/Devices/[0-9]+',path):
|
||||
speed=subprocess.run(['/usr/bin/busctl','--system','--timeout=2','--json=short','get-property',
|
||||
'org.freedesktop.NetworkManager',path,'org.freedesktop.NetworkManager.Device.Wireless','Bitrate'],
|
||||
capture_output=True,text=True,timeout=3)
|
||||
if speed.returncode==0:
|
||||
try:
|
||||
kbps=json.loads(speed.stdout)['data']
|
||||
if isinstance(kbps,list):kbps=kbps[0]
|
||||
if type(kbps) is int and kbps>0:info['linkSpeed']=kbps/1000
|
||||
except (ValueError,KeyError,TypeError,IndexError):pass
|
||||
return info
|
||||
|
||||
|
||||
def saved_profiles(profile_dir):
|
||||
result = []
|
||||
for path in profile_dir.glob('vulpes-*.nmconnection'):
|
||||
|
|
@ -131,14 +157,89 @@ def profile_text(network, password, uid):
|
|||
'cloned-mac-address=permanent\nssid=' +
|
||||
''.join(str(b) + ';' for b in ssid.encode()) +
|
||||
'\n[ipv4]\nmethod=auto\n[ipv6]\nmethod=auto\n')
|
||||
if network.get('hidden'): text = text.replace('[wifi]\n','[wifi]\nhidden=true\n')
|
||||
if mode != 'open':
|
||||
escaped = password.replace('\\', '\\\\').replace(' ', '\\s')
|
||||
text += f'[wifi-security]\nkey-mgmt={mode}\npsk={escaped}\n'
|
||||
return text
|
||||
|
||||
|
||||
HOTSPOT_UUID = '5e962c71-d3d0-4c39-b4de-460bb63fc045'
|
||||
HOTSPOT_RETURN = Path('/run/tundra-wifi/hotspot-return.json')
|
||||
|
||||
|
||||
def active_wifi_uuid():
|
||||
result=nm('-g','GENERAL.CON-UUID','device','show',DEVICE,timeout=5)
|
||||
value=result.stdout.strip() if result.returncode==0 else ''
|
||||
try:return str(uuid.UUID(value))
|
||||
except ValueError:return None
|
||||
|
||||
|
||||
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':''}
|
||||
if path.exists() and not path.is_symlink():
|
||||
config=configparser.ConfigParser(interpolation=None);config.read(path)
|
||||
result.update(configured=True,ssid=bytes(int(b) for b in config['wifi']['ssid'].split(';') if b).decode(),
|
||||
security='wpa2-psk' if config.has_section('wifi-security') else 'open',
|
||||
password=re.sub(r'\\([\\s])',lambda match:' ' if match[1]=='s' else '\\',config.get('wifi-security','psk',fallback='')))
|
||||
return result
|
||||
|
||||
|
||||
def hotspot(request, profile_dir):
|
||||
action=request.get('operation')
|
||||
allowed={'hotspotStatus':set(),'hotspotSave':{'ssid','security','password'},'hotspotPower':{'enabled'}}
|
||||
if action not in allowed or set(request)-{'operation'}-allowed[action]:raise WifiError('WIFI_INVALID_REQUEST')
|
||||
if action=='hotspotStatus':return hotspot_state(profile_dir)
|
||||
target=profile_dir/'vulpes-hotspot.nmconnection'
|
||||
if action=='hotspotSave':
|
||||
ssid=request.get('ssid');security=request.get('security');password=request.get('password')
|
||||
if not isinstance(ssid,str) or not 1<=len(ssid.encode())<=32 or any(ord(c)<32 for c in ssid):raise WifiError('WIFI_INVALID_REQUEST')
|
||||
if security not in ('open','wpa2-psk') or not isinstance(password,str):raise WifiError('WIFI_INVALID_REQUEST')
|
||||
if any(ord(c)<32 for c in password) or (security=='wpa2-psk' and not 8<=len(password.encode())<=63):raise WifiError('WIFI_INVALID_PASSWORD')
|
||||
if active_wifi_uuid()==HOTSPOT_UUID:raise WifiError('HOTSPOT_IN_USE')
|
||||
previous=target.read_text() if target.exists() and not target.is_symlink() else None
|
||||
value=profile_text({'ssid':ssid,'security':'wpa-psk' if security=='wpa2-psk' else 'open'},password,HOTSPOT_UUID)
|
||||
value=value.replace('id=Vulpes Wi-Fi','id=Vulpes hotspot',1).replace('autoconnect=true','autoconnect=false',1).replace('mode=infrastructure','mode=ap\nband=bg',1).replace('[ipv4]\nmethod=auto','[ipv4]\nmethod=shared',1).replace('[ipv6]\nmethod=auto','[ipv6]\nmethod=disabled',1)
|
||||
if security=='wpa2-psk':value=value.replace('[wifi-security]\n','[wifi-security]\nproto=rsn\n')
|
||||
profile_dir.mkdir(mode=0o700,parents=True,exist_ok=True);private_write(target,value)
|
||||
if nm('connection','load',str(target)).returncode:
|
||||
if previous is not None:private_write(target,previous);nm('connection','load',str(target))
|
||||
else:
|
||||
nm('connection','delete','uuid',HOTSPOT_UUID)
|
||||
target.unlink(missing_ok=True)
|
||||
raise WifiError('HOTSPOT_SAVE_FAILED')
|
||||
return hotspot_state(profile_dir)
|
||||
enabled=request.get('enabled')
|
||||
if type(enabled) is not bool:raise WifiError('WIFI_INVALID_REQUEST')
|
||||
current=active_wifi_uuid()
|
||||
if enabled:
|
||||
if not target.is_file() or target.is_symlink():raise WifiError('HOTSPOT_NOT_CONFIGURED')
|
||||
if current==HOTSPOT_UUID:return hotspot_state(profile_dir)
|
||||
HOTSPOT_RETURN.parent.mkdir(mode=0o700,parents=True,exist_ok=True)
|
||||
private_write(HOTSPOT_RETURN,json.dumps({'uuid':current}))
|
||||
if nm('connection','up','uuid',HOTSPOT_UUID).returncode or active_wifi_uuid()!=HOTSPOT_UUID:
|
||||
nm('connection','down','uuid',HOTSPOT_UUID)
|
||||
if current:nm('connection','up','uuid',current)
|
||||
HOTSPOT_RETURN.unlink(missing_ok=True)
|
||||
raise WifiError('HOTSPOT_START_FAILED')
|
||||
elif current==HOTSPOT_UUID:
|
||||
if nm('connection','down','uuid',HOTSPOT_UUID).returncode:raise WifiError('HOTSPOT_STOP_FAILED')
|
||||
previous=None
|
||||
try:previous=str(uuid.UUID(json.loads(HOTSPOT_RETURN.read_text()).get('uuid')))
|
||||
except (OSError,ValueError,TypeError,AttributeError):pass
|
||||
HOTSPOT_RETURN.unlink(missing_ok=True)
|
||||
if previous and previous!=HOTSPOT_UUID:
|
||||
if nm('connection','up','uuid',previous).returncode:raise WifiError('WIFI_RECONNECT_FAILED')
|
||||
result=hotspot_state(profile_dir)
|
||||
if result['enabled']!=enabled:raise WifiError('HOTSPOT_STATE_MISMATCH')
|
||||
return result
|
||||
|
||||
|
||||
def dispatch(request, profile_dir=PROFILE_DIR):
|
||||
if not isinstance(request, dict) or set(request) - {'operation', 'id', 'password', 'enabled'}:
|
||||
if isinstance(request,dict) and request.get('operation') in ('hotspotStatus','hotspotSave','hotspotPower'):
|
||||
return hotspot(request,profile_dir)
|
||||
if not isinstance(request, dict) or set(request) - {'operation', 'id', 'password', 'enabled', 'ssid', 'security'}:
|
||||
raise WifiError('WIFI_INVALID_REQUEST')
|
||||
operation = request.get('operation')
|
||||
saved = saved_profiles(profile_dir)
|
||||
|
|
@ -168,15 +269,25 @@ 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)
|
||||
return {'enabled': enabled, 'networks': found}
|
||||
info={}
|
||||
if any(n['connected'] for n in found):
|
||||
try:info=connection_info()
|
||||
except (WifiError,OSError,subprocess.TimeoutExpired):pass
|
||||
return {'enabled': enabled, 'networks': found, 'connectionInformation':info}
|
||||
if operation == 'disconnect':
|
||||
if nm('device', 'disconnect', DEVICE).returncode:
|
||||
raise WifiError('WIFI_DISCONNECT_FAILED')
|
||||
return {'disconnected': True}
|
||||
if operation != 'connect':
|
||||
if operation not in ('connect','connectHidden'):
|
||||
raise WifiError('WIFI_INVALID_REQUEST')
|
||||
# Resolve an observed AP; clients cannot supply nmcli arguments or arbitrary SSIDs.
|
||||
network = next((row for row in networks() if row['id'] == request.get('id')), None)
|
||||
if operation == 'connectHidden':
|
||||
ssid=request.get('ssid');security=request.get('security')
|
||||
if not isinstance(ssid,str) or not 1<=len(ssid.encode())<=32 or security not in ('open','wpa-psk','sae'):
|
||||
raise WifiError('WIFI_INVALID_REQUEST')
|
||||
network={'ssid':ssid,'security':security,'hidden':True}
|
||||
else:
|
||||
network = next((row for row in networks() if row['id'] == request.get('id')), None)
|
||||
if network is None:
|
||||
raise WifiError('WIFI_NETWORK_GONE')
|
||||
stored = next((row for row in saved if row['ssid'] == network['ssid'] and row['security'] == network['security']), None)
|
||||
|
|
@ -184,6 +295,8 @@ def dispatch(request, profile_dir=PROFILE_DIR):
|
|||
target = profile_dir / ('vulpes-' + uid + '.nmconnection')
|
||||
previous = target.read_text() if stored else None
|
||||
text = previous if stored and not request.get('password') else profile_text(network, request.get('password', ''), uid)
|
||||
if network.get('hidden') and 'hidden=true' not in text:
|
||||
text = text.replace('[wifi]\n', '[wifi]\nhidden=true\n')
|
||||
profile_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
target = profile_dir / ('vulpes-' + uid + '.nmconnection')
|
||||
connected = False
|
||||
|
|
@ -226,7 +339,12 @@ def handle(stream):
|
|||
_, uid, _ = struct.unpack('3i', stream.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
|
||||
if uid not in (0, 32011):
|
||||
raise WifiError('PERMISSION_DENIED')
|
||||
result = {'result': dispatch(receive(stream, MAX_REQUEST))}
|
||||
request = receive(stream, MAX_REQUEST)
|
||||
if isinstance(request,dict) and request.get('operation') in ('connect','connectHidden','forget','disconnect','setEnabled','hotspotSave','hotspotPower'):
|
||||
with MUTATION_LOCK:
|
||||
result = {'result': dispatch(request)}
|
||||
else:
|
||||
result = {'result': dispatch(request)}
|
||||
except WifiError as error:
|
||||
result = {'error': str(error)}
|
||||
except Exception:
|
||||
|
|
@ -247,7 +365,8 @@ def main():
|
|||
if len(data) > MAX_REQUEST:
|
||||
raise WifiError('WIFI_INVALID_REQUEST')
|
||||
with socket.socket(socket.AF_UNIX) as stream:
|
||||
stream.settimeout(90)
|
||||
value=json.loads(data)
|
||||
stream.settimeout(180 if isinstance(value,dict) and value.get('operation')=='hotspotPower' else 90)
|
||||
stream.connect(SOCKET)
|
||||
stream.sendall(json.dumps(json.loads(data)).encode() + b'\n')
|
||||
result = receive(stream, 65536)
|
||||
|
|
@ -265,10 +384,18 @@ def main():
|
|||
os.chown(SOCKET, 0, 32011)
|
||||
path.chmod(0o660)
|
||||
server.listen(4)
|
||||
while True:
|
||||
stream, _ = server.accept()
|
||||
with stream:
|
||||
handle(stream)
|
||||
# Reading saved profiles must not wait behind a radio scan or connection.
|
||||
# Bound both workers and queued sockets; mutations remain serialized.
|
||||
slots = threading.BoundedSemaphore(8)
|
||||
def serve_client(stream):
|
||||
try:
|
||||
with stream: handle(stream)
|
||||
finally: slots.release()
|
||||
with ThreadPoolExecutor(max_workers=4) as workers:
|
||||
while True:
|
||||
stream, _ = server.accept()
|
||||
if slots.acquire(blocking=False): workers.submit(serve_client,stream)
|
||||
else: stream.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
|||
22
tundra/tests/test_bluetooth_control.py
Normal file
22
tundra/tests/test_bluetooth_control.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
spec=importlib.util.spec_from_file_location('bluetooth',Path(__file__).resolve().parents[1]/'runtime/bluetooth-control.py')
|
||||
bt=importlib.util.module_from_spec(spec);spec.loader.exec_module(bt)
|
||||
|
||||
class Bluetooth(unittest.TestCase):
|
||||
def test_reject_arbitrary_path_command_and_non_boolean_power(self):
|
||||
for value in [dict(operation='exec',command='id'),dict(operation='pair',address='/org/bluez/hci0'),
|
||||
dict(operation='power',enabled='false'),dict(operation='rename',name='\nName')]:
|
||||
with self.assertRaises(ValueError):bt.validate(dict(version=1,**value))
|
||||
def test_valid_pair_and_name(self):
|
||||
self.assertEqual(bt.validate(dict(version=1,operation='pair',address='AA:BB:CC:DD:EE:FF')),'pair')
|
||||
self.assertEqual(bt.validate(dict(version=1,operation='rename',name='Vulpes OS')),'rename')
|
||||
def test_cod_for_gaia_and_no_manufactured_connection(self):
|
||||
value=bt.device_record(dict(Address='AA:BB:CC:DD:EE:FF',Class=0x240404,Paired=True))
|
||||
self.assertEqual(value['cod']['majorDeviceClass'],4)
|
||||
self.assertEqual(value['cod']['minorDeviceClass'],1)
|
||||
self.assertTrue(value['paired']);self.assertFalse(value['connected'])
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
69
tundra/tests/test_hotspot.py
Normal file
69
tundra/tests/test_hotspot.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
spec=importlib.util.spec_from_file_location('wifi',Path(__file__).resolve().parents[1]/'runtime/wifi-control.py')
|
||||
wifi=importlib.util.module_from_spec(spec);spec.loader.exec_module(wifi)
|
||||
|
||||
class HotspotTests(unittest.TestCase):
|
||||
def test_private_profile_roundtrip_and_validation(self):
|
||||
with tempfile.TemporaryDirectory() as directory, patch.object(wifi,'active_wifi_uuid',return_value=None), patch.object(wifi,'nm',return_value=SimpleNamespace(returncode=0)) as nm:
|
||||
root=Path(directory)
|
||||
request={'operation':'hotspotSave','ssid':'Vulpes é','security':'wpa2-psk','password':r' space\s tail '}
|
||||
value=wifi.dispatch(request,root)
|
||||
self.assertEqual(value['password'],request['password']);self.assertEqual(value['ssid'],request['ssid'])
|
||||
path=root/'vulpes-hotspot.nmconnection'
|
||||
self.assertEqual(path.stat().st_mode&0o777,0o600)
|
||||
self.assertIn('method=shared',path.read_text());self.assertIn('proto=rsn',path.read_text())
|
||||
self.assertEqual(wifi.saved_profiles(root),[])
|
||||
for invalid in ({'ssid':'x\ninject'},{'password':'short'},{'security':'wep'},{'ssid':'é'*17},{'extra':True}):
|
||||
before=path.read_bytes();count=nm.call_count
|
||||
with self.assertRaises(wifi.WifiError):wifi.dispatch(request|invalid,root)
|
||||
self.assertEqual(path.read_bytes(),before);self.assertEqual(nm.call_count,count)
|
||||
with patch.object(wifi,'active_wifi_uuid',return_value=wifi.HOTSPOT_UUID):
|
||||
with self.assertRaisesRegex(wifi.WifiError,'HOTSPOT_IN_USE'):wifi.dispatch(request,root)
|
||||
|
||||
def test_password_is_not_rewritten_as_profile_configuration(self):
|
||||
with tempfile.TemporaryDirectory() as directory, patch.object(wifi,'active_wifi_uuid',return_value=None), patch.object(wifi,'nm',return_value=SimpleNamespace(returncode=0)):
|
||||
for password in ['autoconnect=true','mode=infrastructure','id=Vulpes Wi-Fi']:
|
||||
result=wifi.dispatch({'operation':'hotspotSave','ssid':'test','security':'wpa2-psk','password':password},Path(directory))
|
||||
self.assertEqual(result['password'],password)
|
||||
|
||||
def test_save_failure_restores_previous(self):
|
||||
with tempfile.TemporaryDirectory() as directory, patch.object(wifi,'active_wifi_uuid',return_value=None):
|
||||
root=Path(directory);path=root/'vulpes-hotspot.nmconnection'
|
||||
request={'operation':'hotspotSave','ssid':'test','security':'wpa2-psk','password':'testpassword'}
|
||||
with patch.object(wifi,'nm',return_value=SimpleNamespace(returncode=1)) as nm:
|
||||
with self.assertRaisesRegex(wifi.WifiError,'HOTSPOT_SAVE_FAILED'):wifi.dispatch(request,root)
|
||||
self.assertFalse(path.exists());self.assertIn(('connection','delete','uuid',wifi.HOTSPOT_UUID),[c.args for c in nm.call_args_list])
|
||||
path.write_text('previous')
|
||||
with self.assertRaisesRegex(wifi.WifiError,'HOTSPOT_SAVE_FAILED'):wifi.dispatch(request,root)
|
||||
self.assertEqual(path.read_text(),'previous')
|
||||
|
||||
def test_power_readback_and_reconnect_after_failure_or_stop(self):
|
||||
previous='8a16e3b3-c49b-44ca-9041-c3bac464f748'
|
||||
for fail in (False,True):
|
||||
active=[previous];commands=[]
|
||||
def nm(*args,**kwargs):
|
||||
commands.append(args)
|
||||
if args[:2]==('connection','up'):
|
||||
if fail and args[-1]==wifi.HOTSPOT_UUID:return SimpleNamespace(returncode=1)
|
||||
active[0]=args[-1]
|
||||
if args[:2]==('connection','down'):active[0]=None
|
||||
return SimpleNamespace(returncode=0)
|
||||
with tempfile.TemporaryDirectory() as directory, patch.object(wifi,'active_wifi_uuid',side_effect=lambda:active[0]),patch.object(wifi,'nm',side_effect=nm):
|
||||
root=Path(directory)
|
||||
with patch.object(wifi,'HOTSPOT_RETURN',root/'return.json'):
|
||||
wifi.dispatch({'operation':'hotspotSave','ssid':'test','security':'open','password':''},root)
|
||||
if fail:
|
||||
with self.assertRaisesRegex(wifi.WifiError,'HOTSPOT_START_FAILED'):wifi.dispatch({'operation':'hotspotPower','enabled':True},root)
|
||||
else:
|
||||
self.assertTrue(wifi.dispatch({'operation':'hotspotPower','enabled':True},root)['enabled'])
|
||||
self.assertFalse(wifi.dispatch({'operation':'hotspotPower','enabled':False},root)['enabled'])
|
||||
self.assertEqual(active[0],previous);self.assertFalse(wifi.HOTSPOT_RETURN.exists())
|
||||
self.assertIn(('connection','up','uuid',previous),commands)
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
69
tundra/tests/test_mms_control.py
Normal file
69
tundra/tests/test_mms_control.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import base64,contextlib,importlib.util,json,tempfile,unittest,subprocess,sys,shutil
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
spec=importlib.util.spec_from_file_location('mms_control',ROOT/'tundra/runtime/mms-control.py');m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||
class MMS(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp=tempfile.TemporaryDirectory();self.old=m.ROOT;m.ROOT=Path(self.tmp.name)
|
||||
def tearDown(self):m.ROOT=self.old;self.tmp.cleanup()
|
||||
def test_reject_paths_and_oversize(self):
|
||||
value={'recipients':['+15550199'],'parts':[{'id':'../secret','type':'text/plain','data':'AA=='}]}
|
||||
with self.assertRaises(ValueError):m.validate_send(value)
|
||||
value['parts'][0]['id']='text.txt';value['parts'][0]['data']=base64.b64encode(b'x'*(m.LIMIT+1)).decode()
|
||||
with self.assertRaises(ValueError):m.validate_send(value)
|
||||
def test_queue_acceptance_is_not_delivery(self):
|
||||
with patch.object(m,'codec',return_value={'data':'AA==','transaction':'t'}):
|
||||
r=m.request({'version':1,'operation':'send','recipients':['+15550199'],'parts':[{'id':'text.txt','type':'text/plain','data':'AA=='}]})
|
||||
self.assertEqual(m.request({'version':1,'operation':'state','path':r['path']})['state'],'pending')
|
||||
def test_operator_acceptance_required(self):
|
||||
row={'direction':'outgoing','encoded':{'data':'AA==','transaction':'t'}}
|
||||
with patch.object(m,'bearer',return_value=contextlib.nullcontext({'url':'http://mmsc.invalid'})),patch.object(m,'transfer',return_value=b'confirmation'),patch.object(m,'codec',return_value={'accepted':False}):
|
||||
with self.assertRaisesRegex(ValueError,'MMS_REJECTED'):m.process(row)
|
||||
def test_ack_does_not_delete_outgoing_or_unread_queue(self):
|
||||
m.save(m.ROOT/'queue.json',[{'id':'o','direction':'outgoing','state':'pending'},{'id':'i','direction':'incoming','state':'received','message':{'receipt':'i'}}])
|
||||
m.request({'version':1,'operation':'ack','ids':['o','i']});r=m.records()
|
||||
self.assertEqual(r[0]['state'],'pending');self.assertEqual(r[1]['state'],'stored');self.assertNotIn('message',r[1])
|
||||
def test_incoming_network_error_keeps_notification_retryable(self):
|
||||
m.save(m.ROOT/'queue.json',[{'id':'i','direction':'incoming','state':'pending','notification':{}}])
|
||||
with patch.object(m,'process',side_effect=ValueError('MMS_DATA_UNAVAILABLE')):self.assertTrue(m.process_once())
|
||||
row=m.records()[0];self.assertEqual(row['state'],'pending');self.assertGreater(row['retryAt'],0)
|
||||
self.assertFalse(m.process_once())
|
||||
def test_outgoing_transport_error_is_never_retried_automatically(self):
|
||||
m.save(m.ROOT/'queue.json',[{'id':'o','direction':'outgoing','state':'pending'}])
|
||||
with patch.object(m,'process',side_effect=ValueError('MMS_TRANSPORT_ERROR')):m.process_once()
|
||||
self.assertEqual(m.records()[0]['state'],'failed');self.assertFalse(m.process_once())
|
||||
def test_retrieval_ack_retry_survives_gaia_acknowledgement(self):
|
||||
m.save(m.ROOT/'queue.json',[{'id':'i','direction':'incoming','state':'received','ackPending':True,'notification':{},'message':{}}])
|
||||
m.request({'version':1,'operation':'ack','ids':['i']})
|
||||
with patch.object(m,'process',return_value={'ackPending':False}):m.process_once()
|
||||
row=m.records()[0];self.assertEqual(row['state'],'stored');self.assertFalse(row['ackPending'])
|
||||
def test_connection_failure_closes_http_socket(self):
|
||||
from unittest.mock import Mock
|
||||
sock=Mock();sock.connect.side_effect=OSError('network unavailable')
|
||||
with patch.object(m.socket,'socket',return_value=sock):
|
||||
with self.assertRaises(OSError):m.transfer({'proxy':True,'url':'http://mmsc.invalid','host':'192.0.2.1','port':8080,'interface':'test0','address':'192.0.2.2'},'http://mmsc.invalid',b'body')
|
||||
sock.close.assert_called_once()
|
||||
@unittest.skipUnless(shutil.which('dbus-run-session') and importlib.util.find_spec('dbus') and importlib.util.find_spec('gi'),'D-Bus/GLib integration dependencies unavailable')
|
||||
def test_private_bus_cleanup_does_not_exit_the_daemon(self):
|
||||
script='''
|
||||
import os,sys,importlib.util
|
||||
os.environ['DBUS_SYSTEM_BUS_ADDRESS']=os.environ['DBUS_SESSION_BUS_ADDRESS']
|
||||
from dbus.mainloop.glib import DBusGMainLoop
|
||||
from gi.repository import GLib
|
||||
DBusGMainLoop(set_as_default=True)
|
||||
s=importlib.util.spec_from_file_location('mms',sys.argv[1]);m=importlib.util.module_from_spec(s);s.loader.exec_module(m)
|
||||
loop=GLib.MainLoop()
|
||||
def close_bearer():
|
||||
try:
|
||||
with m.bearer():pass
|
||||
except Exception:pass
|
||||
GLib.timeout_add(100,alive)
|
||||
return False
|
||||
def alive():
|
||||
print('daemon-survived');loop.quit();return False
|
||||
GLib.idle_add(close_bearer);loop.run()
|
||||
'''
|
||||
r=subprocess.run(['dbus-run-session',sys.executable,'-c',script,str(ROOT/'tundra/runtime/mms-control.py')],capture_output=True,text=True,timeout=5)
|
||||
self.assertEqual(r.returncode,0,r.stderr);self.assertIn('daemon-survived',r.stdout)
|
||||
if __name__=='__main__':unittest.main()
|
||||
115
tundra/tests/test_mobile_settings.py
Normal file
115
tundra/tests/test_mobile_settings.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
spec=importlib.util.spec_from_file_location('mobile',Path(__file__).resolve().parents[1]/'runtime/mobile-settings.py')
|
||||
mobile=importlib.util.module_from_spec(spec);spec.loader.exec_module(mobile)
|
||||
|
||||
class MobileSettingsTests(unittest.TestCase):
|
||||
def test_sim_result_excludes_subscriber_identity_and_pin_counters(self):
|
||||
with patch.object(mobile,'properties',return_value={'Present':True,'CardIdentifier':'test-card','SubscriberIdentity':'secret','Retries':{'pin':3},'PinRequired':'none','ServiceProviderName':'Test','FixedDialing':False}):
|
||||
result=mobile.request({'version':1,'operation':'mobileSettings','action':'sim'})
|
||||
self.assertEqual(result['id'],'test-card')
|
||||
self.assertNotIn('secret',str(result));self.assertNotIn('Retries',result)
|
||||
def test_only_read_queries_allowed(self):
|
||||
for value in ({'action':'setPin'},{'action':'sim','command':'anything'},{'action':'calls','value':True}):
|
||||
with self.assertRaises(ValueError):mobile.request(value)
|
||||
def test_smsc_has_no_message_content(self):
|
||||
with patch.object(mobile,'properties',return_value={'ServiceCenterAddress':'test-center','Messages':['private']}):
|
||||
self.assertEqual(mobile.request({'action':'smsc'}),{'address':'test-center','deliveryReports':None})
|
||||
def test_call_query_cannot_report_unknown_as_disabled(self):
|
||||
with patch.object(mobile,'properties',return_value={}):
|
||||
self.assertIsNone(mobile.request({'action':'calls'})['VoiceCallWaiting'])
|
||||
def test_report_setting_validates_boolean_and_reads_back_modem(self):
|
||||
from types import SimpleNamespace
|
||||
with self.assertRaises(ValueError):mobile.request({'action':'deliveryReports','enabled':'true'})
|
||||
with patch.object(mobile.subprocess,'run',return_value=SimpleNamespace(returncode=0)) as run, patch.object(mobile,'properties',return_value={'UseDeliveryReports':True}):
|
||||
self.assertEqual(mobile.request({'action':'deliveryReports','enabled':True}),{'deliveryReports':True})
|
||||
self.assertEqual(run.call_args.args[0][-4:],['sv','UseDeliveryReports','b','true'])
|
||||
with patch.object(mobile.subprocess,'run',return_value=SimpleNamespace(returncode=0)), patch.object(mobile,'properties',return_value={'UseDeliveryReports':False}):
|
||||
with self.assertRaises(RuntimeError):mobile.request({'action':'deliveryReports','enabled':True})
|
||||
def test_data_write_requires_modem_readback(self):
|
||||
with patch.object(mobile,'set_property') as setter, patch.object(mobile,'properties',return_value={'Powered':False}):
|
||||
with self.assertRaisesRegex(RuntimeError,'RADIO_REQUEST_FAILED'):
|
||||
mobile.request({'action':'dataEnabled','enabled':True})
|
||||
setter.assert_called_once_with('ConnectionManager','Powered','b',True)
|
||||
def test_clock_rejects_path_and_type_injection_before_execution(self):
|
||||
with patch.object(mobile.subprocess,'run') as command:
|
||||
for value in ({'action':'setTimezone','timezone':'../../etc/passwd'}, {'action':'setTimezone','timezone':'/etc/passwd'},
|
||||
{'action':'setTime','milliseconds':True}, {'action':'setTime','milliseconds':float('nan')},
|
||||
{'action':'clock','timezone':'UTC'}, {'action':'setTime','milliseconds':0}):
|
||||
with self.assertRaises(ValueError):mobile.device_request(value)
|
||||
command.assert_not_called()
|
||||
def test_clock_returns_actual_service_state(self):
|
||||
from types import SimpleNamespace
|
||||
with patch.object(mobile.subprocess,'run',return_value=SimpleNamespace(returncode=0,stdout='Timezone=Europe/Paris\nNTP=no\nCanNTP=no\n')), patch.object(mobile,'clock_automatic',return_value=False):
|
||||
self.assertEqual(mobile.device_request({'action':'clock'}), {'timezone':'Europe/Paris','automatic':False,'automaticAvailable':True})
|
||||
def test_apn_read_never_returns_password_or_ims_context(self):
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
props=lambda kind:{'Type':{'data':kind},'Password':{'data':'private-secret'},'AccessPointName':{'data':'example'},'Active':{'data':False}}
|
||||
payload={'data':[[['/ril_0/context1',props('internet')],['/ril_0/context3',props('ims')]]]}
|
||||
with patch.object(mobile.subprocess,'run',return_value=SimpleNamespace(returncode=0,stdout=json.dumps(payload))):
|
||||
rows=mobile.request({'action':'apns'})
|
||||
self.assertEqual(len(rows),1);self.assertTrue(rows[0]['hasPassword'])
|
||||
self.assertNotIn('private-secret',str(rows))
|
||||
def test_apn_edit_reads_back_and_rolls_back_failed_write(self):
|
||||
path='/ril_0/context1';original={'Type':'internet','Active':False,'AccessPointName':'example','Username':'old'}
|
||||
state=dict(original)
|
||||
def write(path,name,value):state[name]=value
|
||||
with patch.object(mobile,'contexts',side_effect=lambda:{path:dict(state)}),patch.object(mobile,'set_context',side_effect=write):
|
||||
result=mobile.request({'action':'editApn','id':path,'fields':{'apn':'new.example','user':'new'}})
|
||||
self.assertEqual(result['fields']['apn'],'new.example');self.assertEqual(state['Username'],'new')
|
||||
state=dict(original)
|
||||
def fail(path,name,value):
|
||||
state[name]=value
|
||||
if name=='Username' and value=='new':raise RuntimeError('APN_WRITE_FAILED')
|
||||
with patch.object(mobile,'contexts',side_effect=lambda:{path:dict(state)}),patch.object(mobile,'set_context',side_effect=fail):
|
||||
with self.assertRaisesRegex(RuntimeError,'APN_WRITE_FAILED'):
|
||||
mobile.request({'action':'editApn','id':path,'fields':{'apn':'new.example','user':'new'}})
|
||||
self.assertEqual(state,original)
|
||||
def test_apn_failed_rollback_is_not_reported_as_restored(self):
|
||||
original={'/ril_0/context1':{'Type':'internet','Active':False,'AccessPointName':'example'}}
|
||||
with patch.object(mobile,'contexts',side_effect=[original,RuntimeError('RADIO_NOT_READY')]),patch.object(mobile,'set_context',side_effect=RuntimeError('APN_WRITE_FAILED')):
|
||||
with self.assertRaisesRegex(RuntimeError,'APN_ROLLBACK_FAILED'):
|
||||
mobile.request({'action':'editApn','id':'/ril_0/context1','fields':{'apn':'new.example'}})
|
||||
def test_apn_rejects_in_use_profiles_unknown_fields_and_injection(self):
|
||||
with patch.object(mobile,'contexts',return_value={'/ril_0/context1':{'Type':'internet','Active':True,'AccessPointName':'example'}}),patch.object(mobile,'set_context') as write:
|
||||
with self.assertRaisesRegex(RuntimeError,'APN_IN_USE'):mobile.request({'action':'editApn','id':'/ril_0/context1','fields':{'apn':'new.example'}})
|
||||
for fields in ({'command':'id'},{'apn':'bad\nname'},{'protocol':'evil'},{'mmsc':'file:///etc/passwd'},{'password':'x'*513}):
|
||||
with self.assertRaises(ValueError):mobile.request({'action':'editApn','id':'/ril_0/context1','fields':fields})
|
||||
with self.assertRaises(ValueError):mobile.request({'action':'editApn','id':'/ril_0/voicecall1','fields':{'apn':'new'}})
|
||||
write.assert_not_called()
|
||||
def test_radio_modes_match_real_modem_capabilities(self):
|
||||
state={'TechnologyPreference':'lte','AvailableTechnologies':['gsm','umts','lte']}
|
||||
def write(interface,name,signature,value):state[name]=value
|
||||
with patch.object(mobile,'properties',side_effect=lambda _:dict(state)),patch.object(mobile,'set_property',side_effect=write):
|
||||
self.assertEqual(mobile.request({'action':'setRadioMode','mode':'any'})['mode'],'any')
|
||||
with self.assertRaises(ValueError):mobile.request({'action':'setRadioMode','mode':'nr'})
|
||||
def test_supplementary_settings_validate_and_confirm_network_state(self):
|
||||
state={'HideCallerId':'default','VoiceCallWaiting':'enabled'}
|
||||
def write(interface,name,signature,value,**kwargs):state[name]=value
|
||||
with patch.object(mobile,'set_property',side_effect=write) as setter,patch.object(mobile,'properties',side_effect=lambda *a,**k:dict(state)):
|
||||
self.assertEqual(mobile.request({'action':'setCallWaiting','enabled':False})['VoiceCallWaiting'],'disabled')
|
||||
self.assertEqual(mobile.request({'action':'setCallerId','mode':'enabled'})['HideCallerId'],'enabled')
|
||||
setter.assert_called_with('CallSettings','HideCallerId','s','enabled',timeout=20)
|
||||
with patch.object(mobile,'set_property'),patch.object(mobile,'properties',return_value={'HideCallerId':'unknown'}):
|
||||
with self.assertRaisesRegex(RuntimeError,'RADIO_READBACK_FAILED'):mobile.request({'action':'setCallerId','mode':'default'})
|
||||
with patch.object(mobile,'set_property') as setter:
|
||||
for value in ({'action':'setCallWaiting','enabled':'true'},{'action':'setCallerId','mode':'secret'},{'action':'calls','number':'1'}):
|
||||
with self.assertRaises(ValueError):mobile.request(value)
|
||||
setter.assert_not_called()
|
||||
def test_forwarding_only_changes_requested_rule_and_checks_readback(self):
|
||||
state={'VoiceUnconditional':'','VoiceBusy':'123','VoiceNoReply':'','VoiceNotReachable':''}
|
||||
def write(interface,name,signature,value,**kwargs):state[name]=value
|
||||
with patch.object(mobile,'set_property',side_effect=write),patch.object(mobile,'properties',side_effect=lambda *a,**k:dict(state)):
|
||||
result=mobile.request({'action':'setForwarding','reason':'noReply','enabled':True,'number':'+33123456789'})
|
||||
self.assertEqual(result['noReply'],'+33123456789');self.assertEqual(result['busy'],'123')
|
||||
result=mobile.request({'action':'setForwarding','reason':'noReply','enabled':False,'number':''})
|
||||
self.assertEqual(result['noReply'],'');self.assertEqual(result['busy'],'123')
|
||||
with patch.object(mobile,'set_property') as setter:
|
||||
for value in ({'reason':'all','enabled':False,'number':''},{'reason':'busy','enabled':True,'number':'*123#'},{'reason':'busy','enabled':False,'number':'123'}):
|
||||
with self.assertRaises(ValueError):mobile.request({'action':'setForwarding',**value})
|
||||
setter.assert_not_called()
|
||||
if __name__=='__main__':unittest.main()
|
||||
31
tundra/tests/test_network_clock.py
Normal file
31
tundra/tests/test_network_clock.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import importlib.util,json,tempfile,unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
spec=importlib.util.spec_from_file_location('mobile',Path(__file__).parents[1]/'runtime/mobile-settings.py')
|
||||
m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m)
|
||||
class NetworkClock(unittest.TestCase):
|
||||
def test_monotonic_age_is_added_to_nitz(self):
|
||||
payload={'data':[{'UTC':{'data':1791836110},'Received':{'data':530}}]}
|
||||
with patch.object(m.subprocess,'run',return_value=SimpleNamespace(returncode=0,stdout=json.dumps(payload))),patch.object(m.time,'monotonic',return_value=550):
|
||||
self.assertEqual(m.network_time(),1791836130)
|
||||
def test_rejects_future_or_missing_received_timestamp(self):
|
||||
for received in (None,999999,True):
|
||||
payload={'data':[{'UTC':{'data':1791836110},'Received':{'data':received}}]}
|
||||
with patch.object(m.subprocess,'run',return_value=SimpleNamespace(returncode=0,stdout=json.dumps(payload))),patch.object(m.time,'monotonic',return_value=550):
|
||||
with self.assertRaisesRegex(RuntimeError,'NETWORK_TIME_UNAVAILABLE'):m.network_time()
|
||||
def test_manual_mode_persists_and_prevents_network_write(self):
|
||||
with tempfile.TemporaryDirectory() as folder,patch.object(m,'CLOCK_CONFIG',Path(folder)/'clock.json'),patch.object(m,'network_time') as read,patch.object(m.time,'clock_settime') as write:
|
||||
self.assertTrue(m.clock_automatic());m.save_clock_automatic(False)
|
||||
self.assertFalse(m.clock_automatic());m.sync_network_clock();read.assert_not_called();write.assert_not_called()
|
||||
self.assertEqual(m.CLOCK_CONFIG.stat().st_mode & 0o777,0o600)
|
||||
def test_only_corrects_a_real_difference(self):
|
||||
with patch.object(m,'clock_automatic',return_value=True),patch.object(m,'network_time',return_value=1791836130),patch.object(m.time,'time',return_value=1791836129),patch.object(m.time,'clock_settime') as write:
|
||||
m.sync_network_clock();write.assert_not_called()
|
||||
with patch.object(m,'clock_automatic',return_value=True),patch.object(m,'network_time',return_value=1791836130),patch.object(m.time,'time',return_value=1791000000),patch.object(m.time,'clock_settime') as write:
|
||||
m.sync_network_clock();write.assert_called_once_with(m.time.CLOCK_REALTIME,1791836130)
|
||||
def test_manual_write_rejected_in_auto_mode(self):
|
||||
with patch.object(m,'clock_automatic',return_value=True),patch.object(m.subprocess,'run') as command:
|
||||
with self.assertRaisesRegex(RuntimeError,'AUTOMATIC_TIME_ENABLED'):m.device_request({'action':'setTime','milliseconds':1791836130000})
|
||||
command.assert_not_called()
|
||||
if __name__=='__main__':unittest.main()
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
import importlib.util
|
||||
import json
|
||||
import socket
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
|
@ -12,6 +15,107 @@ spec.loader.exec_module(radio)
|
|||
|
||||
|
||||
class RadioControlTests(unittest.TestCase):
|
||||
def test_message_state_accepts_modem_hex_case_but_not_other_objects(self):
|
||||
inbox = SimpleNamespace(outgoing_state=lambda path: 'sent')
|
||||
spec = SimpleNamespace(loader=SimpleNamespace(exec_module=lambda module: None))
|
||||
with patch.object(radio.importlib.util, 'spec_from_file_location', return_value=spec), \
|
||||
patch.object(radio.importlib.util, 'module_from_spec', return_value=inbox):
|
||||
for path in ['/ril_0/message_18DE1E64E8B16EA9E5E57366556C971B822E2614',
|
||||
'/ril_0/message_ab12']:
|
||||
self.assertEqual(radio.request({'version':1, 'operation':'messageState',
|
||||
'path':path}), {'state':'sent'})
|
||||
for path in ['/ril_0/voicecall01', '/ril_0/message_Z', '/ril_0/message_1/other']:
|
||||
with self.assertRaisesRegex(ValueError, 'INVALID_SMS_PATH'):
|
||||
radio.request({'version':1, 'operation':'messageState', 'path':path})
|
||||
|
||||
def test_slow_settings_transaction_does_not_block_call_controls(self):
|
||||
import threading
|
||||
started, release = threading.Event(), threading.Event()
|
||||
worker = radio.SettingsWorker()
|
||||
settings_server, settings_client = socket.socketpair()
|
||||
voice_server, voice_client = socket.socketpair()
|
||||
def request(value):
|
||||
if value['operation'] == 'mobileSettings':
|
||||
started.set()
|
||||
if not release.wait(2): raise RuntimeError('test settings timeout')
|
||||
return {'saved':True}
|
||||
return {'calls':[]}
|
||||
try:
|
||||
with patch.object(radio,'request',side_effect=request), settings_client, voice_server, voice_client:
|
||||
settings_client.sendall(b'{"version":1,"operation":"mobileSettings","action":"editApn"}\n')
|
||||
self.assertTrue(radio.handle_connection(settings_server,worker))
|
||||
self.assertTrue(started.wait(1))
|
||||
voice_client.sendall(b'{"version":1,"operation":"calls"}\n')
|
||||
self.assertFalse(radio.handle_connection(voice_server,worker))
|
||||
voice_client.settimeout(1)
|
||||
self.assertEqual(json.loads(voice_client.recv(4096))['result'],{'calls':[]})
|
||||
release.set()
|
||||
settings_client.settimeout(1)
|
||||
self.assertTrue(json.loads(settings_client.recv(4096))['result']['saved'])
|
||||
finally:
|
||||
release.set(); worker.close(); settings_server.close()
|
||||
|
||||
def test_stale_queued_settings_are_not_applied(self):
|
||||
worker=radio.SettingsWorker()
|
||||
server, client=socket.socketpair()
|
||||
worker.slots.acquire()
|
||||
try:
|
||||
with client, patch.object(radio.time,'monotonic',return_value=20), patch.object(radio,'request') as request:
|
||||
worker.run(server,{'operation':'mobileSettings'},0)
|
||||
self.assertEqual(json.loads(client.recv(4096)),{'ok':False,'error':'RADIO_BUSY'})
|
||||
request.assert_not_called()
|
||||
finally:worker.close()
|
||||
|
||||
def test_full_settings_queue_is_bounded(self):
|
||||
worker=radio.SettingsWorker()
|
||||
server, client=socket.socketpair()
|
||||
try:
|
||||
for _ in range(8):self.assertTrue(worker.slots.acquire(blocking=False))
|
||||
with server, client:
|
||||
client.sendall(b'{"version":1,"operation":"mobileSettings","action":"sim"}\n')
|
||||
self.assertFalse(radio.handle_connection(server,worker))
|
||||
self.assertEqual(json.loads(client.recv(4096)),{'ok':False,'error':'RADIO_BUSY'})
|
||||
finally:
|
||||
for _ in range(8):worker.slots.release()
|
||||
worker.close()
|
||||
|
||||
def test_closed_client_does_not_crash_broker_or_repeat_sms(self):
|
||||
server, client = socket.socketpair()
|
||||
with server:
|
||||
client.sendall(b'{"version":1,"operation":"sms"}\n')
|
||||
client.close()
|
||||
with patch.object(radio, 'request', return_value={'state':'pending'}) as request:
|
||||
radio.handle_connection(server)
|
||||
request.assert_called_once()
|
||||
|
||||
def test_incomplete_request_cannot_dial(self):
|
||||
server, client = socket.socketpair()
|
||||
with server, client:
|
||||
client.sendall(b'{"version":1,"operation":"dial"}')
|
||||
client.shutdown(socket.SHUT_WR)
|
||||
with patch.object(radio, 'request') as request:
|
||||
radio.handle_connection(server)
|
||||
request.assert_not_called()
|
||||
|
||||
def test_modem_timeout_does_not_disclose_sms_arguments(self):
|
||||
server, client = socket.socketpair()
|
||||
with server, client:
|
||||
client.sendall(b'{"version":1,"operation":"sms"}\n')
|
||||
error = subprocess.TimeoutExpired(['busctl', 'private-number', 'private-body'], 15)
|
||||
with patch.object(radio, 'request', side_effect=error):
|
||||
radio.handle_connection(server)
|
||||
response = json.loads(client.recv(4096))
|
||||
self.assertEqual(response, {'ok':False, 'error':'RADIO_TIMEOUT'})
|
||||
|
||||
def test_slow_client_timeout_does_not_escape_handler(self):
|
||||
from unittest.mock import Mock
|
||||
conn = Mock()
|
||||
conn.recv.side_effect = socket.timeout()
|
||||
with patch.object(radio, 'request') as request:
|
||||
radio.handle_connection(conn)
|
||||
request.assert_not_called()
|
||||
conn.sendall.assert_not_called()
|
||||
|
||||
def test_void_dbus_success_is_not_an_invalid_reply(self):
|
||||
with patch.object(radio.subprocess, 'run', return_value=SimpleNamespace(
|
||||
returncode=0, stdout='', stderr='')):
|
||||
|
|
|
|||
|
|
@ -78,6 +78,21 @@ on boot
|
|||
self.assertNotIn('critical',output)
|
||||
self.assertIn('class core',output)
|
||||
|
||||
def test_hardware_policy_keeps_one_primary_audio_owner(self):
|
||||
from unittest.mock import patch
|
||||
source = '''service vendor.audio-hal-2-0 /vendor/bin/hw/android.hardware.audio@2.0-service
|
||||
class hal
|
||||
service vendor.camera-provider-2-4 /vendor/bin/hw/android.hardware.camera.provider@2.4-service
|
||||
class hal
|
||||
on boot
|
||||
start vendor.audio-hal-2-0
|
||||
start vendor.camera-provider-2-4
|
||||
'''
|
||||
with patch.object(policy, 'SERVICES', policy.SERVICES | policy.HARDWARE_SERVICES):
|
||||
output, report = policy.filter_rc(source)
|
||||
self.assertNotIn('vendor.audio-hal-2-0', output)
|
||||
self.assertIn('vendor.camera-provider-2-4', report['services'])
|
||||
|
||||
def test_display_keeps_its_platform_properties_and_configstore(self):
|
||||
output,report=policy.filter_rc('on post-fs-data\n load_system_props\nservice vendor.configstore-hal /vendor/bin/hw/android.hardware.configstore@1.1-service\n class hal animation\n user system\n')
|
||||
self.assertIn('load_system_props',output)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from pathlib import Path
|
|||
import ssl
|
||||
import tempfile
|
||||
import unittest
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
spec = importlib.util.spec_from_file_location('packages', ROOT/'runtime/runtime-packages.py')
|
||||
|
|
@ -33,9 +34,20 @@ class RuntimePackages(unittest.TestCase):
|
|||
self.assertEqual(packages.stage(root, CACHE), first)
|
||||
self.assertEqual(json.loads((root/'usr/share/vulpes/runtime-packages.json').read_text()), first)
|
||||
self.assertTrue((root/'usr/libexec/bluetooth/bluetoothd').is_file())
|
||||
self.assertFalse((root/'usr/bin/btmon').exists())
|
||||
self.assertTrue((root/'usr/bin/bluetoothctl').is_file())
|
||||
self.assertTrue((root/'usr/lib/audiosystem-passthrough/audiosystem-passthrough').is_file())
|
||||
context = ssl.create_default_context(cafile=str(root/'etc/ssl/certs/ca-certificates.crt'))
|
||||
self.assertGreater(context.cert_store_stats()['x509_ca'], 50)
|
||||
self.assertTrue((root/'usr/lib/ssl/cert.pem').is_file())
|
||||
providers = ET.parse(root/'usr/share/mobile-broadband-provider-info/serviceproviders.xml')
|
||||
self.assertGreater(len(providers.findall('country')), 100)
|
||||
orange = [provider for provider in providers.findall("./country[@code='fr']/provider")
|
||||
if any(n.attrib == {'mcc':'208', 'mnc':'01'}
|
||||
for n in provider.findall('.//network-id'))]
|
||||
self.assertTrue(orange)
|
||||
self.assertTrue(any(apn.get('value') == 'orange' for provider in orange
|
||||
for apn in provider.findall('.//apn')))
|
||||
target = root/'usr/libexec/bluetooth/bluetoothd'
|
||||
target.write_bytes(b'unknown local binary')
|
||||
with self.assertRaisesRegex(ValueError, 'Conflicting package file'):
|
||||
|
|
|
|||
65
tundra/tests/test_telephony_diagnostic.py
Normal file
65
tundra/tests/test_telephony_diagnostic.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
|
||||
spec = importlib.util.spec_from_file_location('diagnostic', Path(__file__).resolve().parents[1]/'tools/diagnose-telephony.py')
|
||||
diagnostic = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(diagnostic)
|
||||
|
||||
|
||||
class DiagnosticTests(unittest.TestCase):
|
||||
def test_qualcomm_attach_crash_is_visible_without_raw_backtrace(self):
|
||||
report = diagnostic.modem_crashes(
|
||||
'F libc: Fatal signal 7 (SIGBUS), fault addr 0x123456 in tid 101 (qcrild)\n'
|
||||
'F DEBUG: qcril_qmi_nas_initial_attach_timeout_handler(void*)\n'
|
||||
'private subscriber text\n')
|
||||
self.assertEqual(report, {'signals':[{'signal':7, 'name':'SIGBUS'}],
|
||||
'initialAttachTimeoutInBacktrace':True})
|
||||
self.assertNotIn('0x123456', json.dumps(report))
|
||||
self.assertNotIn('subscriber', json.dumps(report))
|
||||
|
||||
def test_crash_time_distinguishes_old_events_after_a_radio_restart(self):
|
||||
report = diagnostic.modem_crashes(
|
||||
'10-08 12:37:33.483 50 101 F libc: Fatal signal 7 (SIGBUS) in tid 101 (qcrild)\n')
|
||||
self.assertEqual(report['signals'][0]['logTime'], '10-08 12:37:33.483')
|
||||
|
||||
def test_subscriber_and_cell_identifiers_never_leave_report(self):
|
||||
props = {'Present': {'data': True}, 'SubscriberIdentity': {'data': 'private-imsi'},
|
||||
'SubscriberNumbers': {'data': ['private-number']}, 'CardIdentifier': {'data': 'private-card'}}
|
||||
self.assertEqual(diagnostic.select_properties('SimManager', props), {'Present': True})
|
||||
line = 'src/binder_network.c:binder_network_poll_voice_state_cb() slot1 denied,lte,14,12345,987654321,11'
|
||||
events = diagnostic.registration_events(line+'\n'+line)
|
||||
self.assertEqual(len(events), 1)
|
||||
self.assertEqual(events[0]['denialReason'], 11)
|
||||
self.assertNotIn('12345', json.dumps(events))
|
||||
self.assertNotIn('987654321', json.dumps(events))
|
||||
|
||||
def test_unavailable_services_are_not_reported_as_healthy(self):
|
||||
def unavailable(command):
|
||||
raise subprocess.TimeoutExpired(command, 12)
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
report = diagnostic.collect(unavailable, Path(folder))
|
||||
self.assertEqual(len(report['errors']), len(diagnostic.FIELDS))
|
||||
self.assertFalse(report['registrationLogAvailable'])
|
||||
self.assertEqual(report['audio'], {'available': False})
|
||||
|
||||
def test_diagnostic_only_reads_fixed_interfaces(self):
|
||||
calls = []
|
||||
def command(args):
|
||||
calls.append(args)
|
||||
if args[0] == 'busctl':
|
||||
return SimpleNamespace(returncode=0, stdout='{"data":[{"Status":{"data":"denied"}}]}')
|
||||
return SimpleNamespace(returncode=0, stdout='active\n' if args[0] == 'systemctl' else '')
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
report = diagnostic.collect(command, Path(folder))
|
||||
self.assertEqual(report['properties']['NetworkRegistration']['Status'], 'denied')
|
||||
for args in calls:
|
||||
if args[0] == 'busctl': self.assertEqual(args[-1], 'GetProperties')
|
||||
if args[0] == 'systemctl': self.assertEqual(args[1], 'is-active')
|
||||
|
||||
|
||||
if __name__ == '__main__': unittest.main()
|
||||
|
|
@ -28,6 +28,16 @@ class WifiTests(unittest.TestCase):
|
|||
self.assertTrue(result[0]['connected'])
|
||||
self.assertEqual(result[1]['security'], 'unsupported')
|
||||
|
||||
def test_connection_information_uses_validated_address_and_actual_bitrate(self):
|
||||
def nm(*args, **kwargs):
|
||||
value='invalid\n192.0.2.4/24\n' if args[1]=='IP4.ADDRESS' else '/org/freedesktop/NetworkManager/Devices/4\n'
|
||||
return SimpleNamespace(returncode=0,stdout=value)
|
||||
with patch.object(wifi,'nm',side_effect=nm), patch.object(wifi.subprocess,'run',return_value=SimpleNamespace(returncode=0,stdout='{"data":[351000]}')):
|
||||
self.assertEqual(wifi.connection_info(),{'ipAddress':'192.0.2.4','linkSpeed':351})
|
||||
with patch.object(wifi,'nm',return_value=SimpleNamespace(returncode=0,stdout='/invalid/path')), patch.object(wifi.subprocess,'run') as run:
|
||||
self.assertEqual(wifi.connection_info(),{})
|
||||
run.assert_not_called()
|
||||
|
||||
def test_secret_persists_only_after_success(self):
|
||||
for fail in (False, True):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
|
|
@ -86,6 +96,18 @@ class WifiTests(unittest.TestCase):
|
|||
with self.assertRaises(wifi.WifiError):
|
||||
wifi.profile_text(AP, password, 'test')
|
||||
|
||||
def test_hidden_network_uses_encoded_ssid_and_private_profile(self):
|
||||
with tempfile.TemporaryDirectory() as folder, patch.object(wifi,'nm',return_value=SimpleNamespace(returncode=0)):
|
||||
root=Path(folder)
|
||||
wifi.dispatch({'operation':'connectHidden','ssid':'Hidden; réseau','security':'wpa-psk','password':'testpassword'},root)
|
||||
saved=next(root.iterdir())
|
||||
self.assertIn('hidden=true',saved.read_text())
|
||||
self.assertEqual(stat.S_IMODE(saved.stat().st_mode),0o600)
|
||||
self.assertEqual(wifi.saved_profiles(root)[0]['ssid'],'Hidden; réseau')
|
||||
for ssid in ('','x'*33,'é'*17):
|
||||
with tempfile.TemporaryDirectory() as folder, self.assertRaises(wifi.WifiError):
|
||||
wifi.dispatch({'operation':'connectHidden','ssid':ssid,'security':'open'},Path(folder))
|
||||
|
||||
def test_socket_round_trip_and_limits(self):
|
||||
a, b = socket.socketpair()
|
||||
with a, b, patch.object(wifi, 'dispatch', return_value={'networks':[]}) as dispatch:
|
||||
|
|
|
|||
|
|
@ -181,33 +181,10 @@ def main():
|
|||
('SYSTEM_IMAGE=/installed/tundra/images/'+sha+'.ext4\nSYSTEM_IMAGE_SHA='+sha+'\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.
|
||||
buffer = io.BytesIO()
|
||||
selected = [*sorted((ROOT.parent/'host').rglob('*')),
|
||||
*sorted((ROOT.parent/'services').rglob('*.mjs')),
|
||||
*sorted((ROOT.parent/'assets/webapps/marketplace.gaiamobile.org').rglob('*')),
|
||||
ROOT.parent/'tools/installed_apps.py',
|
||||
*sorted((ROOT.parent/'gaia/shared/elements/vulpes-fonts').rglob('*')),
|
||||
*sorted((ROOT.parent/'overrides').rglob('*')),
|
||||
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/views/form.js',
|
||||
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/contacts.js',
|
||||
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/activities.js',
|
||||
ROOT.parent/'assets/webapps/search.gaiamobile.org/manifest.webapp',
|
||||
ROOT.parent/'assets/webapps/homescreen.gaiamobile.org/gaia_build_defer_index.js',
|
||||
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_pick.js',
|
||||
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/gaia_build_defer_manage.js',
|
||||
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/js/tone_player.js',
|
||||
ROOT.parent/'tools/project.py',
|
||||
ROOT.parent/'tools/serve-gaia.py',
|
||||
ROOT.parent/'release.json', ROOT.parent/'tests/ProbeChild.sys.mjs',
|
||||
ROOT/'droidian/probe.sys.mjs', ROOT/'droidian/session.py']
|
||||
with tarfile.open(fileobj=buffer, mode='w') as archive:
|
||||
for path in selected:
|
||||
if not path.is_file(): continue
|
||||
relative = str(path.relative_to(ROOT.parent))
|
||||
target = 'trial/'+path.name if path in (ROOT/'droidian/probe.sys.mjs', ROOT/'droidian/session.py') else relative
|
||||
archive.add(path, arcname='opt/vulpes/'+target, filter=owned)
|
||||
shared_ui[relative] = digest(path)
|
||||
files['shared-ui.tar'] = (0o600, buffer.getvalue())
|
||||
from shared_ui import build_payload
|
||||
payload, shared_ui = build_payload()
|
||||
files['shared-ui.tar'] = (0o600, payload)
|
||||
sources.append(ROOT/'tools/shared_ui.py')
|
||||
if state:
|
||||
config = ('STATE_IMAGE=/installed/tundra/data/'+state['uuid']+'.ext4\n'
|
||||
'STATE_UUID='+state['uuid']+'\nSTATE_BYTES='+str(state['bytes'])+'\n')
|
||||
|
|
|
|||
116
tundra/tools/diagnose-telephony.py
Normal file
116
tundra/tools/diagnose-telephony.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Report native telephony health without dialing or exposing subscriber data."""
|
||||
import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
|
||||
FIELDS = {
|
||||
'Modem': ('Powered', 'Online', 'Emergency', 'Revision'),
|
||||
'SimManager': ('Present', 'PinRequired', 'LockedPins'),
|
||||
'NetworkRegistration': ('Status', 'Mode', 'Technology', 'Strength'),
|
||||
'RadioSettings': ('TechnologyPreference', 'AvailableTechnologies'),
|
||||
'ConnectionManager': ('Attached', 'Powered', 'RoamingAllowed'),
|
||||
'IpMultimediaSystem': ('Registered', 'VoiceCapable', 'SmsCapable'),
|
||||
'CallVolume': ('Muted', 'SpeakerVolume', 'MicrophoneVolume'),
|
||||
}
|
||||
|
||||
|
||||
def select_properties(interface, properties):
|
||||
return {key: properties[key].get('data') for key in FIELDS[interface]
|
||||
if isinstance(properties.get(key), dict) and 'data' in properties[key]}
|
||||
|
||||
|
||||
def registration_events(text):
|
||||
"""Keep RAT and denial reason; never copy cell location or raw log lines."""
|
||||
events = []
|
||||
pattern = re.compile(
|
||||
r'binder_network_poll_(voice|data)_state_cb\(\).*?slot\d+ '
|
||||
r'(registered|roaming|searching|unregistered|denied|unknown),'
|
||||
r'([^,]*),(-?\d+),(-?\d+),(-?\d+),(-?\d+)')
|
||||
for line in text.splitlines():
|
||||
match = pattern.search(line)
|
||||
if not match:
|
||||
continue
|
||||
domain, status, technology, rat, _lac, _cell, reason = match.groups()
|
||||
event = {'domain': domain, 'status': status,
|
||||
'technology': technology, 'radioTechnology': int(rat),
|
||||
'denialReason': int(reason)}
|
||||
if not events or event != events[-1]:
|
||||
events.append(event)
|
||||
return events[-40:]
|
||||
|
||||
|
||||
def run(command):
|
||||
return subprocess.run(command, capture_output=True, text=True, timeout=12)
|
||||
|
||||
|
||||
def modem_crashes(text):
|
||||
signals = []
|
||||
for line in text.splitlines():
|
||||
if 'qcrild' not in line:
|
||||
continue
|
||||
match = re.search(r'Fatal signal (\d+) \((SIG[A-Z]+)\)', line)
|
||||
if match:
|
||||
event = {'signal': int(match[1]), 'name': match[2]}
|
||||
timestamp = re.match(r'\s*(\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+)\s', line)
|
||||
if timestamp:
|
||||
event['logTime'] = timestamp[1]
|
||||
signals.append(event)
|
||||
return {'signals': signals,
|
||||
'initialAttachTimeoutInBacktrace': 'qcril_qmi_nas_initial_attach_timeout_handler' in text}
|
||||
|
||||
|
||||
def collect(command=run, root=Path('/')):
|
||||
report = {'recordedAt': datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
'properties': {}, 'services': {}, 'errors': {}}
|
||||
for interface in FIELDS:
|
||||
try:
|
||||
result = command(['busctl', '--system', '--timeout=8', '--json=short',
|
||||
'call', 'org.ofono', '/ril_0',
|
||||
'org.ofono.' + interface, 'GetProperties'])
|
||||
if result.returncode:
|
||||
report['errors'][interface] = 'unavailable'
|
||||
continue
|
||||
data = json.loads(result.stdout)['data'][0]
|
||||
report['properties'][interface] = select_properties(interface, data)
|
||||
except (OSError, subprocess.TimeoutExpired, ValueError, KeyError, IndexError, TypeError):
|
||||
report['errors'][interface] = 'unavailable'
|
||||
for service in ('ofono', 'tundra-radio', 'tundra-sms', 'tundra-audio', 'tundra-call-audio'):
|
||||
try:
|
||||
result = command(['systemctl', 'is-active', service])
|
||||
state = result.stdout.strip()
|
||||
report['services'][service] = state if state in (
|
||||
'active', 'inactive', 'failed', 'activating', 'deactivating') else 'unknown'
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
report['services'][service] = 'unknown'
|
||||
try:
|
||||
result = command(['journalctl', '-u', 'ofono', '--since', '-10 min',
|
||||
'--no-pager', '-n', '2000'])
|
||||
report['registrationEvents'] = registration_events(result.stdout)
|
||||
report['registrationLogAvailable'] = result.returncode == 0
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
report['registrationEvents'] = []
|
||||
report['registrationLogAvailable'] = False
|
||||
try:
|
||||
result = command(['lxc-attach', '-n', 'tundra-hal', '--', '/system/bin/logcat',
|
||||
'-b', 'crash', '-d', '-t', '300'])
|
||||
report['qualcommCrashes'] = modem_crashes(result.stdout)
|
||||
report['qualcommCrashes']['logAvailable'] = result.returncode == 0
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
report['qualcommCrashes'] = {'logAvailable': False}
|
||||
try:
|
||||
data = json.loads((root/'run/tundra-call-audio/status.json').read_text())
|
||||
report['audio'] = {key: data[key] for key in (
|
||||
'active', 'routed', 'speaker', 'muted', 'volume', 'recordedAt') if key in data}
|
||||
report['audio']['hasError'] = bool(data.get('error'))
|
||||
except (OSError, ValueError, TypeError):
|
||||
report['audio'] = {'available': False}
|
||||
return report
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(json.dumps(collect(), indent=2))
|
||||
117
tundra/tools/refresh-boot-ui.py
Normal file
117
tundra/tools/refresh-boot-ui.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Refresh shared Gaia files in a verified private RAM boot, preserving hardware."""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shlex
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import io
|
||||
import tarfile
|
||||
from ramdisk_reference import read_newc
|
||||
from shared_ui import build_payload
|
||||
from tundra import ROOT, checked_source, cpio, digest, save
|
||||
|
||||
|
||||
def main():
|
||||
p=argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument('--base',type=Path,required=True)
|
||||
p.add_argument('--expected-sha256',required=True)
|
||||
p.add_argument('--output',type=Path,required=True)
|
||||
p.add_argument('--settings-adapters',action='store_true',help='Also replace native Settings bridges and hotspot firewall configuration')
|
||||
args=p.parse_args();os.umask(0o077)
|
||||
base=args.base.resolve();out=args.output.resolve()
|
||||
build=json.loads((base/'build.json').read_text())
|
||||
image=base/build['image']
|
||||
if digest(image)!=args.expected_sha256 or build['imageSha256']!=args.expected_sha256:
|
||||
raise ValueError('Base image hash mismatch')
|
||||
if not build.get('sourceSnapshot') or not build.get('maintenanceEnabled') or build.get('storageMode')!='raw-userdata-ext4':
|
||||
raise ValueError('Expected a private standalone build with a source snapshot')
|
||||
spec=importlib.util.spec_from_file_location('rescue',ROOT/'tools/test-rescue-boot.py')
|
||||
rescue=importlib.util.module_from_spec(spec);spec.loader.exec_module(rescue)
|
||||
rescue.verify_sources(base,build)
|
||||
payload,hashes=build_payload()
|
||||
packager=checked_source('aosp-mkbootimg')
|
||||
out.mkdir(parents=True,exist_ok=False)
|
||||
with tempfile.TemporaryDirectory(dir=out) as temporary:
|
||||
temp=Path(temporary)
|
||||
text=subprocess.check_output([sys.executable,str(packager/'unpack_bootimg.py'),'--boot_img',str(image),'--out',str(temp/'base'),'--format=mkbootimg'],text=True)
|
||||
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')
|
||||
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')
|
||||
files={name:{**item} for name,item in original.items()}
|
||||
files['shared-ui.tar']['data']=payload
|
||||
native_sources = {}
|
||||
if args.settings_adapters:
|
||||
names = ['radio-control.py','wifi-control.py','mobile-settings.py','camera-preview.py']
|
||||
native_sources = {'etc/tundra/'+name: ROOT/'runtime'/name for name in names}
|
||||
native_sources['etc/NetworkManager/conf.d/80-vulpes-hotspot.conf']=ROOT/'runtime/hotspot-firewall.conf'
|
||||
native_sources['etc/systemd/system/tundra-radio.service.d/clock.conf']=ROOT/'runtime/clock-service.conf'
|
||||
aliases={'usr/sbin/'+name:name+'-legacy' for name in ('iptables','ip6tables')}
|
||||
buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=io.BytesIO(original['native-config.tar']['data'])) as source, tarfile.open(fileobj=buffer,mode='w') as target:
|
||||
for member in source.getmembers():
|
||||
if member.name in native_sources or member.name in aliases: continue
|
||||
target.addfile(member,source.extractfile(member) if member.isfile() else None)
|
||||
for name,path in native_sources.items():
|
||||
content=path.read_bytes();info=tarfile.TarInfo(name);info.mode=0o644;info.size=len(content)
|
||||
info.uid=info.gid=0;target.addfile(info,io.BytesIO(content))
|
||||
for name,link in aliases.items():
|
||||
info=tarfile.TarInfo(name);info.type=tarfile.SYMTYPE;info.linkname=link;info.mode=0o777
|
||||
target.addfile(info)
|
||||
files['native-config.tar']['data']=buffer.getvalue()
|
||||
entries=[]
|
||||
for name,item in sorted(files.items()):
|
||||
if (stat.S_ISCHR(item['mode']) or stat.S_ISBLK(item['mode'])) and name!='dev/console':
|
||||
raise ValueError('Unrecognized device node: '+name)
|
||||
entries.append((name,item['mode'],item['data'],5 if name=='dev/console' else 0,1 if name=='dev/console' else 0))
|
||||
ramdisk=out/'initramfs.cpio.gz';ramdisk.write_bytes(cpio(entries))
|
||||
fields['--ramdisk']=str(ramdisk)
|
||||
command=[sys.executable,str(packager/'mkbootimg.py')]
|
||||
for k,v in fields.items():command.extend([k,v])
|
||||
target=out/'boot.img'
|
||||
subprocess.run(command+['-o',str(target)],check=True)
|
||||
if target.stat().st_size>67108864:raise ValueError('Boot partition size exceeded')
|
||||
subprocess.run([sys.executable,str(packager/'unpack_bootimg.py'),'--boot_img',str(target),'--out',str(temp/'verify')],check=True,stdout=subprocess.DEVNULL)
|
||||
if digest(temp/'verify/kernel')!=build['kernelSha256'] or digest(temp/'verify/ramdisk')!=digest(ramdisk):
|
||||
raise ValueError('Boot roundtrip failed')
|
||||
verified=read_newc((temp/'verify/ramdisk').read_bytes())
|
||||
if verified.keys()!=original.keys():raise ValueError('Ramdisk entry list changed')
|
||||
changed=[name for name in original if (verified[name]['mode'],verified[name]['data'])!=(original[name]['mode'],original[name]['data'])]
|
||||
permitted={'native-config.tar','shared-ui.tar'} if args.settings_adapters else {'shared-ui.tar'}
|
||||
if set(changed)-permitted:raise ValueError('Unexpected changes: '+str(changed))
|
||||
# A native-only fix may reproduce the same shared UI archive exactly.
|
||||
# Still verify every replacement, including unchanged payloads.
|
||||
for name in permitted:
|
||||
if (verified[name]['mode'],verified[name]['data']) != (files[name]['mode'],files[name]['data']):
|
||||
raise ValueError('Replacement mismatch: '+name)
|
||||
shutil.copytree(base/'sources',out/'sources')
|
||||
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 [])):
|
||||
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),
|
||||
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')
|
||||
save(out/'build.json',build)
|
||||
rescue.verify_sources(out,build)
|
||||
print(json.dumps({'image':str(out/'boot.img'),'sha256':build['imageSha256'],
|
||||
'changed':changed,'sharedFiles':len(hashes),'flashed':False}))
|
||||
|
||||
|
||||
if __name__=='__main__':main()
|
||||
51
tundra/tools/shared_ui.py
Normal file
51
tundra/tools/shared_ui.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Shared Gaia payload used by full builds and private UI-only RAM updates."""
|
||||
import io
|
||||
import tarfile
|
||||
from tundra import ROOT, digest
|
||||
|
||||
|
||||
def selected_files():
|
||||
return [*sorted((ROOT.parent/'host').rglob('*')),
|
||||
*sorted((ROOT.parent/'services').rglob('*.mjs')),
|
||||
*sorted((ROOT/'mms-codec').glob('*')),
|
||||
*sorted((ROOT.parent/'assets/webapps/marketplace.gaiamobile.org').rglob('*')),
|
||||
ROOT.parent/'tools/installed_apps.py',
|
||||
*sorted((ROOT.parent/'gaia/shared/elements/vulpes-fonts').rglob('*')),
|
||||
*sorted((ROOT.parent/'overrides').rglob('*')),
|
||||
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/views/form.js',
|
||||
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/contacts.js',
|
||||
ROOT.parent/'assets/webapps/communications.gaiamobile.org/contacts/js/activities.js',
|
||||
ROOT.parent/'assets/webapps/search.gaiamobile.org/manifest.webapp',
|
||||
ROOT.parent/'assets/webapps/homescreen.gaiamobile.org/gaia_build_defer_index.js',
|
||||
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/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',
|
||||
ROOT.parent/'assets/webapps/ringtones.gaiamobile.org/js/tone_player.js',
|
||||
ROOT.parent/'gaia/apps/communications/dialer/js/telephony_helper.js',
|
||||
ROOT.parent/'tools/project.py',
|
||||
ROOT.parent/'tools/serve-gaia.py',
|
||||
ROOT.parent/'release.json', ROOT.parent/'tests/ProbeChild.sys.mjs',
|
||||
ROOT/'droidian/probe.sys.mjs', ROOT/'droidian/session.py']
|
||||
|
||||
|
||||
def build_payload():
|
||||
buffer = io.BytesIO()
|
||||
hashes = {}
|
||||
def owned(info):
|
||||
info.uid = info.gid = 0
|
||||
info.uname = info.gname = 'root'
|
||||
info.mtime = 0
|
||||
return info
|
||||
with tarfile.open(fileobj=buffer, mode='w') as archive:
|
||||
for path in selected_files():
|
||||
if not path.is_file():
|
||||
continue
|
||||
relative = str(path.relative_to(ROOT.parent))
|
||||
target = 'trial/' + path.name if path in (ROOT/'droidian/probe.sys.mjs', ROOT/'droidian/session.py') else relative
|
||||
archive.add(path, arcname='opt/vulpes/' + target, filter=owned)
|
||||
hashes[relative] = digest(path)
|
||||
return buffer.getvalue(), hashes
|
||||
Loading…
Reference in a new issue