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:
OverSu 2026-10-13 00:19:45 +02:00
commit 1311bed5e9
170 changed files with 19698 additions and 2495 deletions

View file

@ -1,6 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<uses-permission android:name="com.android.alarm.permission.SET_ALARM" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

View file

@ -102,6 +102,18 @@ final class AndroidServices {
);
result.complete(null);
break;
case "android.storage":
io.execute(() -> {
try {
File data = new File(app.getApplicationInfo().dataDir);
File mediaRoot = new File(app.getFilesDir(), "media");
JSONObject stats = new JSONObject().put("free", data.getUsableSpace())
.put("apps", storageBytes(data, mediaRoot)).put("sdcard", storageBytes(mediaRoot, null))
.put("scope", "vulpes-profile");
main.post(() -> result.complete(stats));
} catch (Exception e) { fail(result, e); }
});
break;
case "android.media":
JSONObject params = p;
io.execute(() -> {
@ -122,6 +134,15 @@ final class AndroidServices {
return result;
}
private static long storageBytes(File file, File skip) throws IOException {
if (file.equals(skip) || !file.exists() || !file.getCanonicalFile().equals(file.getAbsoluteFile())) return 0;
if (file.isFile()) return file.length();
long size = 0;
File[] children = file.listFiles();
if (children != null) for (File child : children) size += storageBytes(child, skip);
return size;
}
private void fail(GeckoResult<Object> result, Exception e) {
main.post(() -> {
try {

View file

@ -550,12 +550,17 @@ public final class MainActivity extends Activity {
"http".equals(origin.getScheme()) &&
"camera.localhost".equals(origin.getHost()) &&
origin.getPort() == 18765;
if (!camera || (audio != null && audio.length > 0) || video == null || video.length == 0) {
boolean hasVideo = video != null && video.length > 0;
boolean hasAudio = audio != null && audio.length > 0;
if (!camera || (!hasVideo && !hasAudio)) {
callback.reject();
return;
}
permission(new String[] { android.Manifest.permission.CAMERA }, granted -> {
if (granted) callback.grant(video[0], null);
java.util.ArrayList<String> requested = new java.util.ArrayList<>();
if (hasVideo) requested.add(android.Manifest.permission.CAMERA);
if (hasAudio) requested.add(android.Manifest.permission.RECORD_AUDIO);
permission(requested.toArray(new String[0]), granted -> {
if (granted) callback.grant(hasVideo ? video[0] : null, hasAudio ? audio[0] : null);
else callback.reject();
});
}

View file

@ -1,3 +1,4 @@
import {Input} from './services/input.mjs';
import { Packages, packageURL } from './services/packages.mjs';
import { Activities, isGaiaActivity } from './services/activities.mjs';
import { authorizePlatform } from './services/platform.mjs';
@ -10,6 +11,8 @@ import { Connections } from './Connections.mjs';
import { Alarms } from './Alarms.mjs';
const peers = new Set();
let activities, packages;
const input = new Input({identity:peer=>peer.app,broadcast});
let editSequence=0;
const nativePackages=(operation,data={})=>browser.runtime.sendNativeMessage('vulpes',{type:'packages.'+operation,...data}).then(JSON.parse);
async function refreshInstalled(){const installed=await nativePackages('list');apps.splice(0,apps.length,...apps.filter(a=>!a.installed),...installed);}
let apps, values, settings, dataStores, contacts, messages, connections, alarms, storage;
@ -31,7 +34,7 @@ function grants(app) {
function broadcast(type, data, target) {
for (const peer of peers) {
if (target && peer.app.id !== target) continue;
if (type === 'settingchange' && data.key !== 'language.current' && !grants(peer.app).includes('settings.read')) continue;
if (type === 'settingchange' && !['language.current', 'vulpes.theme'].includes(data.key) && !grants(peer.app).includes('settings.read')) continue;
if (
type === 'contactchange' &&
!peer.app.manifest.permissions?.contacts?.access?.includes('read')
@ -90,6 +93,7 @@ const ready = (async () => {
await browser.storage.local.set({ settings: values });
}
activities = new Activities({identity,apps:()=>apps,notify,uuid:()=>crypto.randomUUID(),
choose:data=>broadcast('activity-choice',data,'system'),
launch:(target,path,{reload=false,entryPoint}={})=>launch(target,entryPoint,path,reload)});
let writes = Promise.resolve();
settings = new SettingsService(
@ -100,7 +104,12 @@ const ready = (async () => {
const next = { ...values, ...Object.fromEntries(entries) };
await browser.storage.local.set({ settings: next });
values = next;
for (const [key, value] of entries) broadcast('settingchange', { key, value });
for (const [key, value] of entries) {
broadcast('settingchange', { key, value });
if(key==='keyboard.gaia.enabled') {
input.hide();broadcast('input-mode',{enabled:value===true});
}
}
});
writes = write.catch(() => {});
return write;
@ -302,6 +311,9 @@ async function handle(peer, request) {
}
if (!Object.keys(params.values).length) return null;
}
if (method === 'input.config') return values['keyboard.gaia.enabled'] === true;
if (method === 'input.request') return input.request(peer,params);
if (method === 'appearance.current') return values['vulpes.theme'] || 'light';
if (method === 'locale.current') return values['language.current'] || 'en-US';
if (method === 'settings.snapshot') {
if (!grants(app).includes('settings.read')) throw Error('PERMISSION_DENIED');
@ -357,6 +369,7 @@ async function handle(peer, request) {
return null;
}
if (method === 'activities.cancel') return activities.cancel(peer);
if (method === 'activities.select') return activities.select(peer,params);
if (method === 'activities.subscribe') return activities.subscribe(peer);
if (method === 'activities.finish') return activities.finish(peer,params);
if (method === 'activities.start' && isGaiaActivity(params)) return activities.start(peer,params);
@ -412,6 +425,7 @@ async function handle(peer, request) {
return null;
}
if (method === 'desktop.wakelock') return null; // Android Activity owns the visible screen.
if (method === 'storage.stats') {if(app.id!=='settings')throw Error('PERMISSION_DENIED');return android('storage',{});}
if (method === 'media.request') return media(app, params);
throw Error('METHOD_NOT_SUPPORTED');
}
@ -449,6 +463,15 @@ browser.runtime.onConnect.addListener((port) => {
app,
manager: { documentURI: { spec: url.href } },
sendAsyncMessage: (_, event) => notify(peer, event.type, event.data),
edits: new Map(),
sendQuery(_, params) {
const id=++editSequence;
return new Promise((resolve,reject)=>{
const timer=setTimeout(()=>{peer.edits.delete(id);reject(Error('INPUT_CONTEXT_EXPIRED'));},5000);
peer.edits.set(id,{resolve,reject,timer});
port.postMessage({edit:{id,params}});
});
},
};
peers.add(peer);
});
@ -456,6 +479,16 @@ browser.runtime.onConnect.addListener((port) => {
let reply;
try {
await attached;
if(Object.hasOwn(message,'inputFocus')) {
if(values['keyboard.gaia.enabled']===true) input.changed(peer,message.inputFocus);
return;
}
if(Object.hasOwn(message,'editReply')) {
const edit=peer.edits.get(message.editReply);
if(edit) {clearTimeout(edit.timer);peer.edits.delete(message.editReply);
if(message.error)edit.reject(Error(message.error));else edit.resolve(message.result);}
return;
}
reply = { result: await handle(peer, message.request) };
} catch (e) {
reply = { error: { code: e.message || 'HOST_ERROR' } };
@ -469,6 +502,9 @@ browser.runtime.onConnect.addListener((port) => {
attached
.then(() => {
peers.delete(peer);
input.cleanup(peer);
for(const edit of peer.edits.values()) {clearTimeout(edit.timer);edit.reject(Error('INPUT_CONTEXT_EXPIRED'));}
peer.edits.clear();
if (peer.app.id === 'system' && ![...peers].some((p) => p.app.id === 'system'))
systemReady = false;
activities.cleanup(peer);

View file

@ -25,7 +25,23 @@
});
}
function onMessage(message) {
if (message.edit) {
try {
const result=editor.edit(message.edit.params);
result.inputMode=originalModes.get(editor.inputElement) ||
(editor.inputElement.classList.contains('recipient')?'tel':'');
port.postMessage({editReply:message.edit.id, result});
}
catch (error) { port.postMessage({editReply:message.edit.id, error:String(error.message)}); }
return;
}
if (message.event) {
if(message.event.type==='input-mode') {
gaiaKeyboard=message.event.data.enabled===true;
if(!gaiaKeyboard) restoreMode();
win.VulpesNativeInput=gaiaKeyboard;
win.dispatchEvent(new window.Event('vulpes-input-configured'));
}
win.dispatchEvent(
new window.CustomEvent('vulpes-service-event', { detail: cloneInto(message.event, win) }),
);
@ -50,6 +66,45 @@
}, 1000);
}
connect();
const editor = new InputEditor(window);
let gaiaKeyboard = false;
const originalModes = new WeakMap();
function restoreMode() {
const field = editor.inputElement;
if (!field || !originalModes.has(field)) return;
const value = originalModes.get(field);
if (value === null) field.removeAttribute('inputmode');
else field.setAttribute('inputmode', value);
originalModes.delete(field);
}
request({version:1,id:0,method:'input.config',params:{}}).then(reply => {
gaiaKeyboard = reply.result === true;
win.VulpesNativeInput = gaiaKeyboard;
win.dispatchEvent(new window.Event('vulpes-input-configured'));
}).catch(error => console.error('Gaia input configuration:', error));
for (const type of ['focusin', 'pointerdown', 'click']) addEventListener(type, event => {
if (!gaiaKeyboard || editor.editing || location.hostname === 'keyboard.localhost') return;
let element = event.target;
if (element?.isContentEditable) element=element.closest('[contenteditable=true]') || element;
const editable = element?.isContentEditable ||
(['input','textarea'].includes(element?.localName) &&
!['button','submit','range','checkbox','radio','date','time','datetime-local','file'].includes(element.type));
if (!editable || element.disabled || element.readOnly) {
if (type === 'click' && !element?.closest?.('[data-vulpes-keep-input]')) port.postMessage({inputFocus:null});
return;
}
if (element !== editor.inputElement) restoreMode();
editor.focus(element);
if (!originalModes.has(element)) originalModes.set(element, element.getAttribute('inputmode'));
// Suppress Android's IME only in this editor; external web pages keep it.
element.setAttribute('inputmode', 'none');
const state=editor.state();
state.inputMode=originalModes.get(element) || (element.classList.contains('recipient')?'tel':'');
port.postMessage({inputFocus:state});
}, true);
addEventListener('visibilitychange', () => {
if (document.hidden && gaiaKeyboard) { restoreMode(); port.postMessage({inputFocus:null}); }
});
addEventListener('unload', () => clearTimeout(reconnect));
exportFunction(request, win.navigator, { defineAs: 'vulpesRequest' });
const frames = new Map();

View file

@ -25,6 +25,7 @@
"http://*.localhost/*"
],
"js": [
"input-editor.js",
"content.js"
],
"all_frames": true,

View file

@ -125,11 +125,11 @@ result["camera_resumes_from_android_background"] = wait(
"camera",
'!document.hidden && !!app.camera.mozCamera && app.camera.mozCamera!==__streamBefore && app.camera.mozCamera.getVideoTracks().every(t=>t.readyState==="live") && !app.camera.isBusy',
)
result["unavailable_controls"] = ev(
result["capability_controls"] = ev(
"camera",
'[".js-flash",".mode-switch"].every(s=>document.querySelector(s).getAttribute("aria-disabled")==="true")',
'document.querySelector(".js-flash").getAttribute("aria-disabled")==="true" && (document.querySelector(".mode-switch").getAttribute("aria-disabled")!=="true") === !!Object.keys(app.camera.mozCamera.capabilities.recorderProfiles).length',
)
assert result["unavailable_controls"]
assert result["capability_controls"]
result["errors"] = ev(
"camera", '__vulpesDiagnostics.filter(d=>d.kind==="error" || d.kind==="rejection")'
)

View file

@ -12,7 +12,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools"))
from project import build_info, validate_assets, release_info, application_manifest
from project import SOURCE_OVERRIDES, build_info, validate_assets, release_info, application_manifest
validate_assets()
OUT = ROOT / "android/app/src/main/assets"
@ -46,17 +46,8 @@ info = build_info()
release = release_info()
revision = info["revision"]
commit_time = str(info["timestamp"])
source_overrides = {
"system": [
"js/launcher.js",
"js/gaia_scheduler.js",
"js/keyboard_manager.js",
"js/core.js",
"js/rocketbar.js",
]
}
textsuffix = {".html", ".js", ".css", ".json", ".webapp", ".properties", ".svg"}
head = b'<head><script src="/_vulpes/platform-config.js"></script><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"><link rel="stylesheet" href="/_vulpes/desktop.css"><link rel="stylesheet" href="/_vulpes/android.css"><script src="/_vulpes/settings-codec.js"></script><script src="/_vulpes/gaia-compat.js"></script><script src="/_vulpes/gaia-wifi.js"></script><script src="/_vulpes/gaia-services.js"></script><script src="/_vulpes/gaia-styles.js"></script><script src="/_vulpes/android-ui.js"></script><script src="/_vulpes/gaia-restoration.js"></script><script src="/_vulpes/platform-ui.js"></script><script src="/_vulpes/gaia-camera.js"></script>'
head = b'<head><script src="/_vulpes/platform-config.js"></script><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"><link rel="stylesheet" href="/_vulpes/desktop.css"><link rel="stylesheet" href="/_vulpes/android.css"><script src="/_vulpes/settings-codec.js"></script><script src="/_vulpes/gaia-compat.js"></script><script src="/_vulpes/gaia-wifi.js"></script><script src="/_vulpes/gaia-mobile-settings.js"></script><script src="/_vulpes/gaia-bluetooth.js"></script><script src="/_vulpes/gaia-input.js"></script><script src="/_vulpes/gaia-services.js"></script><script src="/_vulpes/gaia-styles.js"></script><link rel="stylesheet" href="/_vulpes/gaia-theme.css"><script src="/_vulpes/gaia-theme.js"></script><script src="/_vulpes/gaia-select.js"></script><script src="/_vulpes/android-ui.js"></script><script src="/_vulpes/gaia-restoration.js"></script><script src="/_vulpes/platform-ui.js"></script><script src="/_vulpes/gaia-camera.js"></script>'
if "--bridge-only" not in sys.argv or not (OUT / "gaia.zip").exists():
with zipfile.ZipFile(OUT / "gaia.zip", "w", zipfile.ZIP_DEFLATED, compresslevel=6) as z:
hashes = {}
@ -82,7 +73,7 @@ if "--bridge-only" not in sys.argv or not (OUT / "gaia.zip").exists():
if p.is_file() and p.name != "manifest.webapp"
}
)
for name in source_overrides.get(app["id"], []):
for name in SOURCE_OVERRIDES.get(app["id"], []):
files[name] = ROOT / "gaia/apps" / app["id"] / name
for name, p in files.items():
data = p.read_bytes()
@ -120,7 +111,7 @@ if "--bridge-only" not in sys.argv or not (OUT / "gaia.zip").exists():
data = translate(data.decode("utf8")).encode()
put("_shared/" + p.relative_to(ROOT / "gaia/shared").as_posix(), data)
put("_vulpes/platform-config.js", b'window.VulpesPlatform="android";')
for name in ["gaia-compat.js", "gaia-services.js", "gaia-styles.js", "gaia-restoration.js", "gaia-wifi.js", "desktop.css", "platform-ui.js", "gaia-camera.js"]:
for name in ["gaia-compat.js", "gaia-input.js", "gaia-services.js", "gaia-styles.js", "gaia-theme.js", "gaia-select.js", "gaia-mobile-settings.js", "gaia-theme.css", "gaia-restoration.js", "gaia-wifi.js", "gaia-bluetooth.js", "desktop.css", "platform-ui.js", "gaia-camera.js"]:
text = (
(ROOT / "host" / name)
.read_text()
@ -145,6 +136,7 @@ for name in ["Connections", "Contacts", "Messages"]:
)
(ext / f"{name}.mjs").write_text(text)
shutil.copy2(ROOT / "host/Alarms.sys.mjs", ext / "Alarms.mjs")
(ext / "input-editor.js").write_text((ROOT / "services/input-editor.mjs").read_text().replace('export class InputEditor', 'class InputEditor'))
shutil.copy2(ROOT / "host/marketplace-page.js", ext / "marketplace-page.js")
(ext / "release.json").write_text(json.dumps(release))
(ext / "registry.json").write_text(json.dumps(registry, ensure_ascii=False))