Compare commits

..
Author SHA1 Message Date
OverSu
200d1fb235 Release Android Preview 21 and bilingual changelog
Add permission-based reading of Android contacts, SMS and call history. Include shared Gaia fixes and document device validation limits. Pixel image qualification remains pending.
2026-10-14 13:13:46 +02:00
OverSu
1c20a0b6d5 Simplify bilingual changelogs by platform and change type
Keep dated history and release notes generated from one source.
2026-10-13 21:37:55 +02:00
OverSu
7c86dbcd33 Document Preview 20 publication and platform qualification
Record the installed Sargo boot, preserved data and Android runtime validation limit. Keep the website and release exports tied to the same changelog source.
2026-10-13 00:24:45 +02:00
44 changed files with 3860 additions and 975 deletions

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-feature android:name="android.hardware.telephony" android:required="false" />
<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" />

View file

@ -0,0 +1,172 @@
package org.vulpes_os.preview;
import android.Manifest;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.database.ContentObserver;
import android.os.Handler;
import android.os.Looper;
import java.util.HashMap;
import java.util.Map;
import android.net.Uri;
import android.provider.CallLog;
import android.provider.ContactsContract;
import android.provider.Telephony;
import org.json.JSONArray;
import org.json.JSONObject;
/** Read-through access. Android remains the owner of contacts and telephony records. */
final class AndroidPersonalData {
private final Context context;
private final Handler main = new Handler(Looper.getMainLooper());
private final Map<String, ContentObserver> observers = new HashMap<>();
private final Map<String, Runnable> pending = new HashMap<>();
void changed(String kind) {
Runnable previous = pending.remove(kind);
if (previous != null) main.removeCallbacks(previous);
Runnable next = () -> {
pending.remove(kind);
VulpesApplication app = (VulpesApplication) context;
if (app.port == null) return;
try { app.port.postMessage(new JSONObject().put("type", "android-data-changed").put("kind", kind)); }
catch (Exception ignored) { /* The resume event refreshes detached sessions. */ }
};
pending.put(kind, next);
main.postDelayed(next, 250);
}
void observe() {
for (String kind : new String[]{"contacts", "sms", "calls"}) {
if (granted(kind) && !observers.containsKey(kind)) {
ContentObserver observer = new ContentObserver(main) {
@Override public void onChange(boolean selfChange) { changed(kind); }
};
Uri uri = kind.equals("contacts") ? ContactsContract.Contacts.CONTENT_URI :
kind.equals("sms") ? Telephony.Sms.CONTENT_URI : CallLog.Calls.CONTENT_URI;
try {
context.getContentResolver().registerContentObserver(uri, true, observer);
observers.put(kind, observer);
} catch (SecurityException ignored) { /* Permission can be revoked during registration. */ }
} else if (!granted(kind) && observers.containsKey(kind)) {
context.getContentResolver().unregisterContentObserver(observers.remove(kind));
}
}
}
AndroidPersonalData(Context context) { this.context = context; }
static String permission(String kind) {
switch (kind) {
case "contacts": return Manifest.permission.READ_CONTACTS;
case "sms": return Manifest.permission.READ_SMS;
case "calls": return Manifest.permission.READ_CALL_LOG;
default: throw new IllegalArgumentException("INVALID_DATA_KIND");
}
}
boolean granted(String kind) {
return context.getSharedPreferences("personal-data", 0).getBoolean(kind, false) &&
context.checkSelfPermission(permission(kind)) == PackageManager.PERMISSION_GRANTED;
}
void enable(String kind, boolean value) {
permission(kind);
context.getSharedPreferences("personal-data", 0).edit().putBoolean(kind, value).apply();
observe();
changed(kind);
}
JSONObject status() throws Exception {
JSONObject result = new JSONObject();
for (String kind : new String[]{"contacts", "sms", "calls"}) result.put(kind, granted(kind));
return result;
}
JSONObject page(JSONObject params) throws Exception {
String kind = params.getString("kind");
if (!granted(kind)) throw new SecurityException("ANDROID_DATA_PERMISSION_DENIED");
long after = params.optLong("after", 0);
if (after < 0) throw new IllegalArgumentException("INVALID_CURSOR");
Uri uri;
String[] columns;
switch (kind) {
case "contacts":
uri = ContactsContract.Contacts.CONTENT_URI;
columns = new String[]{"_id", "display_name", "starred", "contact_last_updated_timestamp"};
break;
case "sms":
uri = Telephony.Sms.CONTENT_URI;
columns = new String[]{"_id", "thread_id", "address", "body", "date", "type", "read", "status"};
break;
default:
uri = CallLog.Calls.CONTENT_URI;
columns = new String[]{"_id", "number", "date", "duration", "type"};
}
JSONArray rows = new JSONArray();
long last = after;
boolean more = false;
ContentResolver resolver = context.getContentResolver();
// A key cursor avoids SQL LIMIT syntax differences between Android providers.
try (Cursor cursor = resolver.query(uri, columns, "_id > ?", new String[]{Long.toString(after)}, "_id ASC")) {
if (cursor == null) throw new IllegalStateException("ANDROID_PROVIDER_UNAVAILABLE");
int bytes = 0;
while (cursor.moveToNext()) {
if (rows.length() >= 100 || bytes > 512 * 1024) { more = true; break; }
JSONObject row = new JSONObject();
for (int i = 0; i < columns.length; i++) {
row.put(columns[i], cursor.isNull(i) ? JSONObject.NULL :
cursor.getType(i) == Cursor.FIELD_TYPE_INTEGER ? cursor.getLong(i) : cursor.getString(i));
}
last = cursor.getLong(0);
if (kind.equals("contacts")) contactDetails(last, row);
rows.put(row);
bytes += row.toString().length() * 2;
}
}
return new JSONObject().put("rows", rows).put("next", more ? last : JSONObject.NULL);
}
private void contactDetails(long id, JSONObject contact) throws Exception {
JSONArray phones = new JSONArray(), emails = new JSONArray();
contact.put("tel", phones).put("email", emails);
try (Cursor c = context.getContentResolver().query(ContactsContract.Data.CONTENT_URI,
new String[]{"mimetype", "data1", "data2", "data3", "is_primary"},
"contact_id = ? AND mimetype IN (?, ?, ?)", new String[]{Long.toString(id),
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE,
ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE,
ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE}, "_id ASC")) {
if (c == null) throw new IllegalStateException("ANDROID_PROVIDER_UNAVAILABLE");
while (c.moveToNext()) {
String mime = c.getString(0);
if (mime.equals(ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)) {
if (!contact.has("givenName")) contact.put("givenName", new JSONArray().put(c.getString(2)))
.put("familyName", new JSONArray().put(c.getString(3)));
} else {
boolean phone = mime.equals(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE);
int type = c.getInt(2);
String label = type == 0 ? c.getString(3) : phone ?
(type == 2 ? "mobile" : type == 1 ? "home" : type == 3 ? "work" : "other") :
(type == 1 ? "home" : type == 2 ? "work" : "other");
(phone ? phones : emails).put(new JSONObject().put("value", c.getString(1))
.put("type", new JSONArray().put(label == null ? "other" : label)).put("pref", c.getInt(4) != 0));
}
}
}
}
Intent open(JSONObject params) {
String kind = params.optString("kind");
if (kind.equals("contacts")) {
String id = params.optString("id");
if (!id.matches("[1-9][0-9]{0,15}")) throw new IllegalArgumentException("INVALID_CONTACT");
if (!granted(kind)) throw new SecurityException("ANDROID_DATA_PERMISSION_DENIED");
return new Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, Long.parseLong(id)));
}
if (kind.equals("sms")) return new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_APP_MESSAGING);
if (kind.equals("calls")) return new Intent(Intent.ACTION_VIEW, CallLog.Calls.CONTENT_URI);
throw new IllegalArgumentException("INVALID_DATA_KIND");
}
}

View file

@ -24,6 +24,7 @@ final class AndroidServices {
CONTACT = 412;
static final long MAX_MEDIA = 32L * 1024 * 1024;
final VulpesApplication app;
final AndroidPersonalData personal;
final ExecutorService io = Executors.newSingleThreadExecutor();
final Handler main = new Handler(Looper.getMainLooper());
GeckoResult<Object> pending;
@ -31,6 +32,7 @@ final class AndroidServices {
AndroidServices(VulpesApplication app) {
this.app = app;
personal = new AndroidPersonalData(app);
}
GeckoResult<Object> request(JSONObject message) {
@ -40,6 +42,30 @@ final class AndroidServices {
JSONObject p = message.optJSONObject("params");
if (p == null) p = new JSONObject();
switch (op) {
case "android.personal":
JSONObject personalParams = p;
String operation = p.optString("operation");
if (operation.equals("permission")) {
String kind = p.getString("kind");
activity().permission(new String[]{AndroidPersonalData.permission(kind)}, granted -> {
try { personal.enable(kind, granted); result.complete(personal.status()); } catch (Exception e) { fail(result, e); }
});
} else if (operation.equals("status")) result.complete(personal.status());
else if (operation.equals("disconnect")) {
personal.enable(p.getString("kind"), false);
result.complete(personal.status());
}
else if (operation.equals("open")) {
activity().startActivity(personal.open(p));
result.complete(null);
} else if (operation.equals("page")) io.execute(() -> {
try {
JSONObject data = personal.page(personalParams);
main.post(() -> result.complete(data));
} catch (Exception e) { fail(result, e); }
});
else throw new IllegalArgumentException("METHOD_NOT_SUPPORTED");
break;
case "android.notification":
JSONObject request = p;
if (p.optString("operation").equals("permission")) {

View file

@ -82,8 +82,8 @@ public final class MainActivity extends Activity {
webPanel.addView(nav, new LinearLayout.LayoutParams(-1, dp(52)));
Button back = new Button(this);
back.setText("‹");
back.setContentDescription("Retour au bureau");
back.setOnClickListener(v -> closeBrowser());
back.setContentDescription("Retour");
back.setOnClickListener(v -> back());
nav.addView(back, new LinearLayout.LayoutParams(dp(55), -1));
address = new EditText(this);
address.setSingleLine(true);
@ -99,6 +99,9 @@ public final class MainActivity extends Activity {
return true;
});
webView = new GeckoView(this);
// The web panel is layered over Gaia and hidden when returning to an app.
// A TextureView follows that visibility without leaving a separate surface.
webView.setViewBackend(GeckoView.BACKEND_TEXTURE_VIEW);
webPanel.addView(webView, new LinearLayout.LayoutParams(-1, 0, 1));
home = new Button(this);
home.setText("◯");
@ -297,10 +300,12 @@ public final class MainActivity extends Activity {
}
void closeBrowser() {
boolean wasVisible = webPanel.getVisibility() == View.VISIBLE;
webPanel.setVisibility(View.GONE);
gaiaView.setVisibility(View.VISIBLE);
setHomeSurface(app.homeVisible);
syncActive();
if (wasVisible) app.browserClosed();
}
private void back() {
@ -470,7 +475,7 @@ public final class MainActivity extends Activity {
else callback.accept(granted);
}
private void permission(String[] names, java.util.function.Consumer<Boolean> callback) {
void permission(String[] names, java.util.function.Consumer<Boolean> callback) {
boolean granted = true;
for (String name : names)
granted &= checkSelfPermission(name) == PackageManager.PERMISSION_GRANTED;

View file

@ -150,6 +150,15 @@ public final class VulpesApplication extends Application {
);
}
void browserClosed() {
if (port == null) return;
try {
port.postMessage(new JSONObject().put("type", "browser-closed"));
} catch (Exception e) {
Log.w("Vulpes", "Browser close notification", e);
}
}
void home(boolean held) {
if (port == null) return;
try {
@ -160,6 +169,7 @@ public final class VulpesApplication extends Application {
}
void resumed() {
if (services != null) services.personal.observe();
sendScreenLock();
if (port == null) return;
try {

View file

@ -1,3 +1,4 @@
import {AndroidPersonalData, authorizePersonal} from './personal-data.mjs';
import {Input} from './services/input.mjs';
import { Packages, packageURL } from './services/packages.mjs';
import { Activities, isGaiaActivity } from './services/activities.mjs';
@ -15,7 +16,7 @@ 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;
let apps, values, settings, dataStores, contacts, messages, connections, alarms, storage, personal;
const identity = (peer) => peer.app;
function notify(peer, type, data) {
try {
@ -121,6 +122,7 @@ const ready = (async () => {
contacts = new Contacts(storage, (data) => broadcast('contactchange', data));
await contacts.init();
messages = new Messages(storage);
personal = new AndroidPersonalData(params=>android("personal",params), contacts, messages, storage);
dataStores = new DataStoreService(apps, storage, (data) => broadcast('datastorechange', data));
connections = new Connections(identity, notify);
alarms = new Alarms(
@ -140,6 +142,7 @@ const ready = (async () => {
})();
// Java owns this port. Web pages have no access to native messaging.
let native;
let activeWebView;
let systemReady = false;
let pendingScreenLock = false;
function deliverScreenLock() {
@ -163,15 +166,26 @@ function connectNative() {
native.onDisconnect.addListener(() => setTimeout(connectNative, 1000));
native.onMessage.addListener(async (message) => {
await ready;
if (message.type === 'browser-closed' && activeWebView) {
const {peer, contextId} = activeWebView;
activeWebView = null;
notify(peer, 'view', {contextId, type:'close', detail:{}});
}
if (['home', 'holdhome'].includes(message.type)) broadcast(message.type, {}, 'system');
if (message.type === 'android-screen-off') {
pendingScreenLock = true;
deliverScreenLock();
}
if (message.type === 'android-data-changed') {
broadcast('android-data-changed', {kind:message.kind}, message.kind === 'sms' ? 'sms' : 'communications');
}
if (message.type === 'android-resume') {
deliverScreenLock();
broadcast('screen-on', {}, 'system');
await refreshAndroid().catch(console.warn);
broadcast('android-data-changed', {kind:'contacts'}, 'communications');
broadcast('android-data-changed', {kind:'calls'}, 'communications');
broadcast('android-data-changed', {kind:'sms'}, 'sms');
}
if (message.type === 'notification-click') {
pendingNotification = message.data;
@ -215,6 +229,12 @@ async function handle(peer, request) {
authorizePlatform(app, method.slice(9), params);
method = 'android.' + method.slice(9);
}
if (method === 'android.personal') {
authorizePersonal(app, params.kind, params.operation);
if (params.operation === 'page') return personal.rows(params.kind);
if (!['status','permission','disconnect','open'].includes(params.operation)) throw Error('METHOD_NOT_SUPPORTED');
return android('personal', params);
}
if (method === 'android.capabilities') {
const release = await (await fetch('release.json')).json();
const device = app.id === 'settings' ? await android('snapshot') : {};
@ -355,8 +375,8 @@ async function handle(peer, request) {
}
if (method === 'datastores.list') return dataStores.list(app, params.name);
if (method === 'datastores.run') return dataStores.run(app, params);
if (method === 'contacts.request') return contacts.request(app, params);
if (method === 'messages.request') return messages.request(app, params);
if (method === 'contacts.request') return personal.contactRequest(app, params);
if (method === 'messages.request') return personal.messageRequest(app, params);
if (method === 'alarms.request') return alarms.request(peer, app, params);
if (method === 'iac.subscribe') {
connections.subscribe(peer);
@ -397,6 +417,7 @@ async function handle(peer, request) {
throw Error('PERMISSION_DENIED');
if (params.operation === 'open') {
await openWeb(params.url);
activeWebView = {peer, contextId:params.contextId};
notify(peer, 'view', {
contextId: params.contextId,
type: 'loadend',
@ -502,6 +523,7 @@ browser.runtime.onConnect.addListener((port) => {
attached
.then(() => {
peers.delete(peer);
if (activeWebView?.peer === peer) activeWebView = null;
input.cleanup(peer);
for(const edit of peer.edits.values()) {clearTimeout(edit.timer);edit.reject(Error('INPUT_CONTEXT_EXPIRED'));}
peer.edits.clear();

View file

@ -37,10 +37,8 @@
}
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'));
inputConfigurationRevision++;
configureInput(message.event.data.enabled===true);
}
win.dispatchEvent(
new window.CustomEvent('vulpes-service-event', { detail: cloneInto(message.event, win) }),
@ -53,6 +51,8 @@
callback.resolve(cloneInto(message.reply, win));
}
function onDisconnect() {
inputConfigurationRevision++;
configureInput(false);
for (const callback of pending.values())
callback.reject(new window.Error('VULPES_HOST_CLOSED'));
pending.clear();
@ -60,14 +60,15 @@
reconnect = setTimeout(() => {
try {
connect();
refreshInputConfiguration();
} catch (_) {
onDisconnect();
}
}, 1000);
}
connect();
const editor = new InputEditor(window);
let gaiaKeyboard = false;
let inputConfigurationRevision = 0;
const originalModes = new WeakMap();
function restoreMode() {
const field = editor.inputElement;
@ -77,21 +78,27 @@
else field.setAttribute('inputmode', value);
originalModes.delete(field);
}
request({version:1,id:0,method:'input.config',params:{}}).then(reply => {
gaiaKeyboard = reply.result === true;
function configureInput(enabled) {
gaiaKeyboard = enabled;
if(!enabled) restoreMode();
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(enabled && !document.hidden && document.hasFocus()) reportFocus(document.activeElement);
}
function refreshInputConfiguration() {
const revision = inputConfigurationRevision;
request({version:1,id:0,method:'input.config',params:{}}).then(reply => {
if(revision===inputConfigurationRevision) configureInput(reply.result===true);
}).catch(error => console.error('Gaia input configuration:', error));
}
function reportFocus(element) {
if (!gaiaKeyboard || editor.editing || location.hostname === 'keyboard.localhost') return false;
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;
return false;
}
if (element !== editor.inputElement) restoreMode();
editor.focus(element);
@ -101,9 +108,18 @@
const state=editor.state();
state.inputMode=originalModes.get(element) || (element.classList.contains('recipient')?'tel':'');
port.postMessage({inputFocus:state});
return true;
}
connect();
refreshInputConfiguration();
for (const type of ['focusin', 'pointerdown', 'click']) addEventListener(type, event => {
if (!gaiaKeyboard || editor.editing || location.hostname === 'keyboard.localhost') return;
if (!reportFocus(event.target) && type === 'click' &&
!event.target?.closest?.('[data-vulpes-keep-input]')) port.postMessage({inputFocus:null});
}, true);
addEventListener('visibilitychange', () => {
if (document.hidden && gaiaKeyboard) { restoreMode(); port.postMessage({inputFocus:null}); }
else if (gaiaKeyboard && document.hasFocus()) reportFocus(document.activeElement);
});
addEventListener('unload', () => clearTimeout(reconnect));
exportFunction(request, win.navigator, { defineAs: 'vulpesRequest' });

View file

@ -0,0 +1,88 @@
import {Contacts} from './Contacts.mjs';
import {Messages} from './Messages.mjs';
export function authorizePersonal(app, kind, operation) {
if (app.installed || !['settings', 'communications', 'sms', 'system'].includes(app.id))
throw Error('PERMISSION_DENIED');
if (operation === 'status' && app.id === 'settings') return;
if (!['contacts', 'sms', 'calls'].includes(kind)) throw Error('INVALID_DATA_KIND');
if (['permission', 'disconnect'].includes(operation)) {
if (app.id !== 'settings') throw Error('PERMISSION_DENIED');
return;
}
if (kind === 'contacts' && !app.manifest.permissions?.contacts?.access?.includes('read')) throw Error('PERMISSION_DENIED');
if (kind === 'sms' && app.id !== 'sms') throw Error('PERMISSION_DENIED');
if (kind === 'calls' && app.id !== 'communications') throw Error('PERMISSION_DENIED');
}
export function contactFromAndroid(row) {
return {id:'android:'+row._id, name:[row.display_name || ''], givenName:row.givenName || [],
familyName:row.familyName || [], tel:row.tel || [], email:row.email || [],
category:row.starred ? ['favorite'] : [], updated:new Date(row.contact_last_updated_timestamp || 0).toISOString()};
}
export function smsFromAndroid(row) {
// Negative IDs keep Android provider rows separate from existing Vulpes messages.
if (![row._id, row.thread_id].every(n => Number.isSafeInteger(n) && n > 0)) throw Error('INVALID_ANDROID_ID');
const incoming = row.type === 1;
return {id:-row._id, threadId:-row.thread_id, type:'sms',
sender:incoming ? row.address : null, receiver:incoming ? null : row.address,
body:row.body || '', timestamp:row.date, read:!!row.read,
delivery:incoming ? 'received' : row.type === 2 ? 'sent' : row.type === 5 ? 'error' : 'sending',
deliveryStatus:row.status === 0 ? 'success' : row.status >= 64 ? 'error' : 'not-applicable'};
}
export class AndroidPersonalData {
constructor(native, contacts, messages, storage) {
Object.assign(this, {native, contacts, messages, storage});
}
async rows(kind) {
const status = await this.native({operation:'status'});
if (!status[kind]) return [];
const rows = [];
let after = 0;
do {
const page = await this.native({operation:'page', kind, after});
rows.push(...page.rows);
if (rows.length > 100000) throw Error('ANDROID_DATA_TOO_LARGE');
if (page.next === null) break;
if (!Number.isSafeInteger(page.next) || page.next <= after) throw Error('INVALID_CURSOR');
after = page.next;
} while (true);
return rows;
}
async contactRequest(app, params) {
const write = ['save', 'remove', 'clear'].includes(params.operation);
const access = app.manifest.permissions?.contacts?.access || '';
if (!access.includes(write ? 'write' : 'read')) throw Error('PERMISSION_DENIED');
// Third-party apps keep the local address book and never inherit Android's grant.
if (app.installed || !['communications','sms','system'].includes(app.id)) return this.contacts.request(app, params);
if (write) {
if (String(params.contact?.id || params.id || '').startsWith('android:') ||
params.operation === 'clear' && (await this.native({operation:'status'})).contacts)
throw Error('ANDROID_DATA_READ_ONLY');
return this.contacts.request(app, params);
}
const remote = (await this.rows('contacts')).map(contactFromAndroid);
const local = await this.contacts.request(app, {operation:'all'});
const view = new Contacts(null, ()=>{});
let revision = this.contacts.data.revision;
for (const c of remote) for (const character of JSON.stringify(c)) revision = (revision * 31 + character.charCodeAt(0)) >>> 0;
view.data = {revision, contacts:[...local, ...remote]};
return view.request(app, params);
}
async messageRequest(app, params) {
if (!app.manifest.permissions?.sms) throw Error('PERMISSION_DENIED');
if (app.installed || app.id !== 'sms') return this.messages.request(app, params);
const {operation, args=[]} = params;
if (['delete','markMessageRead'].includes(operation) && [args[0]].flat().some(id => id < 0))
throw Error('ANDROID_DATA_READ_ONLY');
if (!['getMessages','getThreads','getMessage'].includes(operation)) return this.messages.request(app, params);
const remote = (await this.rows('sms')).filter(row => row.type !== 3).map(smsFromAndroid);
const local = await this.messages.request(app, {operation:'getMessages'});
const view = new Messages({run:async(_store, op)=>{
if (op !== 'get') throw Error('ANDROID_DATA_READ_ONLY');
return {value:[...local, ...remote]};
}});
return view.request(app, params);
}
}

View file

@ -3,14 +3,15 @@
import json,sys,time
from rdp import RDP
def evaluate(host, script):
r=RDP()
def evaluate(host, script, connection=None):
hostname, _, path = host.partition('/')
r=connection or RDP()
try:
for process in r.call('root','listProcesses')['processes']:
if process['isParent']: continue
target=r.call(process['actor'],'getTarget')['process']
if 'webIsolated' not in target.get('remoteType',''): continue
text='''(() => { const w=[...Services.ww.getWindowEnumerator()].find(w=>w.location.hostname===%s); if(!w)return null; return w.wrappedJSObject.eval(%s); })()''' % (json.dumps(host+'.localhost'),json.dumps(script))
text='''(() => { const w=[...Services.ww.getWindowEnumerator()].find(w=>w.location.hostname===%s && (!%s || w.location.pathname.startsWith(%s))); if(!w)return null; return w.wrappedJSObject.eval(%s); })()''' % (json.dumps(hostname+'.localhost'),json.dumps(path),json.dumps('/'+path),json.dumps(script))
response=r.call(target['consoleActor'],'evaluateJSAsync',text=text)
if 'resultID' not in response: continue
while True:
@ -20,24 +21,32 @@ def evaluate(host, script):
if value.get('result') is not None and value.get('result') != {'type':'null'}: return value.get('result')
break
raise RuntimeError('App not open: '+host)
finally:r.s.close()
finally:
if connection is None:r.s.close()
def run(host,expression,timeout=30):
def _run(host,expression,timeout,connection):
start=time.monotonic()+timeout
while True:
try:
evaluate(host,'window.__androidProbe=null; Promise.resolve().then(async()=>('+expression+')).then(value=>window.__androidProbe=JSON.stringify({value}),error=>window.__androidProbe=JSON.stringify({error:String(error),stack:error.stack})); "started"')
evaluate(host,'window.__androidProbe=null; Promise.resolve().then(async()=>('+expression+')).then(value=>window.__androidProbe=JSON.stringify({value}),error=>window.__androidProbe=JSON.stringify({error:String(error),stack:error.stack})); "started"',connection)
break
except (RuntimeError, EOFError, OSError) as e:
except RuntimeError as e:
if time.monotonic()>=start:raise
if isinstance(e, RuntimeError) and not str(e).startswith('App not open:'):raise
if not str(e).startswith('App not open:'):raise
time.sleep(.3)
until=time.monotonic()+timeout
while time.monotonic()<until:
result=evaluate(host,'window.__androidProbe || "pending"')
result=evaluate(host,'window.__androidProbe || "pending"',connection)
if isinstance(result,str) and result != 'pending':return json.loads(result)
time.sleep(.3)
raise TimeoutError(host)
def run(host,expression,timeout=30):
connection=RDP()
try:
return _run(host,expression,timeout,connection)
finally:
connection.s.close()
if __name__=='__main__':
print(json.dumps(run(sys.argv[1],sys.argv[2]),ensure_ascii=False,indent=2))

View file

@ -29,6 +29,8 @@ class RDP:
while True:
r = self.recv()
self.events.append(r)
if str(r.get("type", "")).endswith("ListChanged"):
continue
if r.get("from") == to and (r.get("type") not in ["consoleAPICall", "pageError"]):
return r

View file

@ -1 +1,202 @@
// Home layout recovery now lives in the shared host/platform-ui.js.
// Android-owned data stays in its provider; Gaia keeps its local data separately.
(() => {
const app = location.hostname.split('.')[0];
const fr = () => (document.documentElement.lang || navigator.language).startsWith('fr');
const text = (f,e) => fr() ? f : e;
const call = params => VulpesCompat.call('android.personal', params);
const notice = message => window.VulpesGaiaUI?.notice(message);
const readOnly = () => notice(text('Ces données appartiennent à Android. Modifiez-les dans l’application Android.',
'Android owns these records. Edit them in the Android app.'));
const failure = () => notice(text('Accès aux données Android indisponible. Vérifiez les autorisations dans Paramètres.',
'Android data is unavailable. Check permissions in Settings.'));
let dirty = false;
let selectedContact;
const pendingCallChanges = new Set();
function refreshCallLog() {
if (document.hidden || !window.CallLog?.sticky) return;
if (pendingCallChanges.has('calls')) {
CallLog.callLogContainer.textContent = '';
CallLog.render();
} else if (pendingCallChanges.has('contacts')) {
CallLog._contactCache = false;
CallLog.updateListWithContactInfo('update');
}
pendingCallChanges.clear();
}
if(app==='communications' && location.pathname.startsWith('/contacts')) {
// Gaia's first-paint HTML cache must not retain Android names after revocation.
localStorage.removeItem('firstChunk');
document.addEventListener('click',event=>{
const row=event.target.closest?.('[data-uuid]');
if(row) selectedContact=row.dataset.uuid;
},true);
let cache;
Object.defineProperty(window,'Cache',{configurable:true,get:()=>cache,set:value=>{
cache=value;
Object.defineProperties(cache,{
enabled:{get:()=>false},active:{get:()=>false},firstChunk:{set:()=>{}}
});
cache.apply=()=>Promise.reject();
}});
}
function canRefresh() {
if (document.hidden) return false;
if (app === 'sms') return window.Navigation?.isCurrentPanel('thread-list');
if (location.pathname.startsWith('/contacts')) return window.MainNavigation?.currentView() === 'view-contacts-list';
return false;
}
let refreshingMessages = false;
async function refreshMessages() {
const view=window.ConversationView, id=view?.activeThread?.id;
if(refreshingMessages || !(id < 0) || document.hidden || !window.Navigation?.isCurrentPanel('thread'))return;
refreshingMessages=true;
try {
const rows=await VulpesCompat.call('messages.request',{operation:'getMessages',args:[{threadId:id}]});
if(view.activeThread?.id!==id || document.hidden)return;
const ids=new Set(rows.map(row=>String(row.id)));
for(const node of document.querySelectorAll('[data-message-id]')) {
if(Number(node.dataset.messageId)<0 && !ids.has(node.dataset.messageId))view.removeMessageDOM(node);
}
for(const row of rows) {
const key=JSON.stringify(row),old=document.getElementById('message-'+row.id);
if(old?.dataset.androidRecord===key)continue;
const message={...row,timestamp:new Date(row.timestamp)};
window.Threads?.registerMessage(message);
await view.appendMessage(message,false);
const node=document.getElementById('message-'+row.id);if(node)node.dataset.androidRecord=key;
}
dirty=false;
} catch(error){failure();}finally{refreshingMessages=false;}
}
function refresh() {
if (app === 'communications' && location.pathname.startsWith('/dialer')) {
refreshCallLog();
return;
}
if (dirty && selectedContact && !document.hidden && window.MainNavigation?.currentView()==='view-contact-details') {
dirty = false;
const id = selectedContact;
VulpesCompat.call('contacts.request',{operation:'find',options:{filterBy:['id'],filterOp:'equals',filterValue:id}})
.then(rows=>{if(rows.length)Contacts.updateContactDetail(id);else Contacts.goBack();}).catch(failure);
return;
}
if(dirty && app==='sms')refreshMessages();
if (!dirty || !canRefresh()) return;
dirty = false;
if(app==='sms' && window.InboxView?.container && !InboxView.inEditMode) InboxView.renderThreads();
else if(window.contacts?.List?.load) contacts.List.load(undefined,true);
}
addEventListener('visibilitychange', refresh);
addEventListener('hashchange', refresh);
addEventListener('transitionend', refresh);
addEventListener('vulpes-service-event', ({detail}) => {
if (detail.type !== 'android-data-changed') return;
if (app === 'communications' && location.pathname.startsWith('/dialer')) {
pendingCallChanges.add(detail.data.kind);
refreshCallLog();
return;
}
dirty = true;
refresh();
});
// Keep Gaia's call grouping/rendering while reading the current Android log.
if (app === 'communications' && location.pathname.startsWith('/dialer')) {
let value;
Object.defineProperty(window, 'CallLogDBManager', {configurable:true,
get:()=>value, set:db=>{
value=db;
const original = Object.fromEntries(['getGroupList','getGroup','getGroupAtPosition'].map(k=>[k,db[k].bind(db)]));
const groups = async () => {
const rows = await call({operation:'page',kind:'calls'});
const list = new Map();
for (const row of rows) {
const recent = {date:row.date,number:row.number || '',type:row.type===2 ? 'dialing':'incoming',
status:row.type===1 ? 'connected':null};
const id=db._getGroupId(recent), key=JSON.stringify(id);
if(!list.has(key)) list.set(key,{id,number:recent.number,serviceId:0,lastEntryDate:row.date,retryCount:0,calls:[]});
const g=list.get(key);g.lastEntryDate=Math.max(g.lastEntryDate,row.date);g.retryCount++;
g.calls.push({date:row.date,duration:row.duration*1000});
}
return [...list.values()].map(g=>{g.calls.sort((a,b)=>b.date-a.date);return db._getGroupObject(g);});
};
let nativeCalls=false;
const active=async()=> (nativeCalls=!!(await call({operation:'status',kind:'calls'})).calls);
db.getGroupList=async(callback,sortedBy,prev,cursor,limit)=>{
try {
if(!await active()) return original.getGroupList(callback,sortedBy,prev,cursor,limit);
// Native groups do not live in Gaia's IndexedDB contact cache.
if(window.CallLog)CallLog._contactCache=false;
let list=(await groups()).sort((a,b)=>(a.lastEntryDate-b.lastEntryDate)*(prev?-1:1));
if(limit>0)list=list.slice(0,limit);
if(!cursor)return callback(list);
let index=0;
const next=()=>queueMicrotask(()=>callback({value:list[index++] || null,continue:next}));next();
} catch(error) { failure(); callback(cursor?{value:null}:[]); }
};
db.getGroup=async(number,date,type,status)=>{
if(!await active())return original.getGroup(number,date,type,status);
const key=db._getGroupId({number,date,type,status}).join('-');
const found=(await groups()).find(g=>g.id===key);
if(!found)throw Error('NotFoundError');return found;
};
db.getGroupAtPosition=async(position,sortedBy,prev,type,callback)=>{
if(!await active())return original.getGroupAtPosition(position,sortedBy,prev,type,callback);
try {const list=(await groups()).filter(g=>!type||g.type===type).sort((a,b)=>(a.lastEntryDate-b.lastEntryDate)*(prev?-1:1));callback(list[position-1]||null);}
catch(error){failure();callback(null);}
};
document.addEventListener('click', event=>{
if(!nativeCalls || !event.target.closest?.('#call-log-icon-edit'))return;
// Editing Android logs is intentionally delegated in this first integration.
event.preventDefault();event.stopImmediatePropagation();readOnly();
call({operation:'open',kind:'calls'}).catch(failure);
},true);
}});
}
if(app==='communications' && location.pathname.startsWith('/contacts')) {
const timer=setInterval(()=>{
if(!window.Contacts?.showForm)return;
clearInterval(timer);
const original=Contacts.showForm;
Contacts.showForm=function(edit,contact) {
if(edit && String(contact?.id).startsWith('android:')) {
selectedContact=contact.id;
call({operation:'open',kind:'contacts',id:contact.id.slice(8)}).catch(failure);
return;
}
return original.apply(this,arguments);
};
},100);
addEventListener('pagehide',()=>clearInterval(timer),{once:true});
}
function initialize() {
if (app !== 'settings') return;
const root=document.querySelector('#root > div');
if(!root || document.querySelector(".vulpes-android-data"))return;
const header=document.createElement('header');header.className='vulpes-android-data';
const title=document.createElement('h2');title.textContent=text('Données Android','Android data');header.append(title);
const list=document.createElement('ul');list.className='vulpes-android-data';
const hint=document.createElement('p');hint.style.cssText='margin:1rem 1.5rem;font-size:1.4rem;line-height:1.5';
hint.textContent=text('Lecture des contacts, SMS (hors MMS) et appels Android. Envoi, modification et suppression dans Android. Les contacts créés dans Gaia restent locaux.',
'Read Android contacts, SMS (not MMS) and calls. Send, edit and delete in Android. Contacts created in Gaia stay local.');
root.append(header,hint,list);
for(const [kind,french,english] of [['contacts','Contacts','Contacts'],['sms','SMS','SMS'],['calls','Journal d’appels','Call history']]) {
const li=document.createElement('li'),button=document.createElement('button');
li.append(button);list.append(li);
let enabled=false;
const update=state=>{enabled=state[kind];button.textContent=text(french,english)+' — '+(enabled?text('Déconnecter','Disconnect'):text('Autoriser la lecture','Allow reading'));};
call({operation:'status'}).then(update).catch(failure);
button.onclick=async()=>{
button.disabled=true;
try {
const disconnect=enabled;
const state=await call({operation:disconnect?'disconnect':'permission',kind});update(state);
if(!disconnect && !state[kind])notice(text('Autorisation refusée ou restreinte par Android. L’application Android reste disponible.',
'Permission denied or restricted by Android. Your Android app is still available.'));
} catch(error){failure();} finally{button.disabled=false;}
};
}
}
if(app==='settings') new MutationObserver(initialize).observe(document.documentElement,{childList:true,subtree:true});
if(document.readyState==='loading')addEventListener('DOMContentLoaded',initialize,{once:true});else initialize();
})();

View file

@ -405,7 +405,7 @@ window.performance.measure('callLogReady','callLogStart');return;}
self._empty=false;var currDate=new Date(cursor.value.date);batchGroupCounter++;if(!prevDate||(currDate.getTime()==prevDate.getTime())){chunk.push(cursor.value);}else{daysToRender.push(chunk);chunk=[cursor.value];var renderNow=false;if(batchGroupCounter>=MAX_GROUPS_FOR_FIRST_RENDER&&!screenRendered){renderNow=true;screenRendered=true;window.performance.mark('firstChunkReady');}else if(batchGroupCounter>=MAX_GROUPS_TO_BATCH_RENDER){renderNow=true;}
if(renderNow){self.renderSeveralDays(daysToRender);daysToRender=[];batchGroupCounter=0;}}
prevDate=currDate;cursor.continue();},'lastEntryDate',true,true);},renderSeveralDays:function cl_renderSeveralDays(chunks){for(var i=0,l=chunks.length;i<l;i++){this.renderChunk(chunks[i]);}},renderChunk:function cl_renderChunk(chunk){var callLogSection=this.createSectionForGroup(chunk[0]);var phoneNumbers=[];var logGroupContainer=callLogSection.getElementsByTagName('ol')[0];for(var i=0;i<chunk.length;i++){var current=chunk[i];var logGroupDOM=this.createGroup(current);logGroupContainer.appendChild(logGroupDOM);phoneNumbers.push(current.number);}
this.callLogContainer.appendChild(callLogSection);if(!this._contactCache){this.updateListWithContactInfo(null,null,phoneNumbers,logGroupContainer);}},renderEmptyCallLog:function cl_renderEmptyCallLog(isEmptyMissedCallsGroup){this.disableEditModeButton();if(!isEmptyMissedCallsGroup){this._empty=true;}
this.callLogContainer.appendChild(callLogSection);if(!this._contactCache){this.updateListWithContactInfo(null,null,logGroupContainer);}},renderEmptyCallLog:function cl_renderEmptyCallLog(isEmptyMissedCallsGroup){this.disableEditModeButton();if(!isEmptyMissedCallsGroup){this._empty=true;}
var noResultContainer=document.getElementById('no-result-container');noResultContainer.hidden=false;var allCallsMsg1=document.getElementById('no-result-msg1');var allCallsMsg2=document.getElementById('no-result-msg2');var noMissedCallsMsg=document.getElementById('no-result-msg3');allCallsMsg1.hidden=isEmptyMissedCallsGroup||false;allCallsMsg2.hidden=isEmptyMissedCallsGroup||false;noMissedCallsMsg.hidden=!isEmptyMissedCallsGroup;if(!isEmptyMissedCallsGroup){var noResultContainerClone=noResultContainer.cloneNode(true);this.callLogContainer.innerHTML='';this.callLogContainer.appendChild(noResultContainerClone);}},appendGroup:function cl_appendGroup(group){if(!this._initialized){return;}
this.unfilter();this.enableEditModeButton();var logGroupDOM=this.createGroup(group);var dayIndex=group.date;var container=this.callLogContainer;var previousLogGroup=document.getElementById(group.id);if(previousLogGroup){var parent=previousLogGroup.parentNode;parent.removeChild(previousLogGroup);this.insertInSection(logGroupDOM,parent);return logGroupDOM;}
var groupSelector='[data-timestamp="'+dayIndex+'"]';var sectionExists=container.querySelector(groupSelector);if(sectionExists){var section=sectionExists.getElementsByTagName('ol')[0];this.insertInSection(logGroupDOM,section);return logGroupDOM;}

View file

@ -7,7 +7,7 @@ window.performance.measure('callLogReady','callLogStart');return;}
self._empty=false;var currDate=new Date(cursor.value.date);batchGroupCounter++;if(!prevDate||(currDate.getTime()==prevDate.getTime())){chunk.push(cursor.value);}else{daysToRender.push(chunk);chunk=[cursor.value];var renderNow=false;if(batchGroupCounter>=MAX_GROUPS_FOR_FIRST_RENDER&&!screenRendered){renderNow=true;screenRendered=true;window.performance.mark('firstChunkReady');}else if(batchGroupCounter>=MAX_GROUPS_TO_BATCH_RENDER){renderNow=true;}
if(renderNow){self.renderSeveralDays(daysToRender);daysToRender=[];batchGroupCounter=0;}}
prevDate=currDate;cursor.continue();},'lastEntryDate',true,true);},renderSeveralDays:function cl_renderSeveralDays(chunks){for(var i=0,l=chunks.length;i<l;i++){this.renderChunk(chunks[i]);}},renderChunk:function cl_renderChunk(chunk){var callLogSection=this.createSectionForGroup(chunk[0]);var phoneNumbers=[];var logGroupContainer=callLogSection.getElementsByTagName('ol')[0];for(var i=0;i<chunk.length;i++){var current=chunk[i];var logGroupDOM=this.createGroup(current);logGroupContainer.appendChild(logGroupDOM);phoneNumbers.push(current.number);}
this.callLogContainer.appendChild(callLogSection);if(!this._contactCache){this.updateListWithContactInfo(null,null,phoneNumbers,logGroupContainer);}},renderEmptyCallLog:function cl_renderEmptyCallLog(isEmptyMissedCallsGroup){this.disableEditModeButton();if(!isEmptyMissedCallsGroup){this._empty=true;}
this.callLogContainer.appendChild(callLogSection);if(!this._contactCache){this.updateListWithContactInfo(null,null,logGroupContainer);}},renderEmptyCallLog:function cl_renderEmptyCallLog(isEmptyMissedCallsGroup){this.disableEditModeButton();if(!isEmptyMissedCallsGroup){this._empty=true;}
var noResultContainer=document.getElementById('no-result-container');noResultContainer.hidden=false;var allCallsMsg1=document.getElementById('no-result-msg1');var allCallsMsg2=document.getElementById('no-result-msg2');var noMissedCallsMsg=document.getElementById('no-result-msg3');allCallsMsg1.hidden=isEmptyMissedCallsGroup||false;allCallsMsg2.hidden=isEmptyMissedCallsGroup||false;noMissedCallsMsg.hidden=!isEmptyMissedCallsGroup;if(!isEmptyMissedCallsGroup){var noResultContainerClone=noResultContainer.cloneNode(true);this.callLogContainer.innerHTML='';this.callLogContainer.appendChild(noResultContainerClone);}},appendGroup:function cl_appendGroup(group){if(!this._initialized){return;}
this.unfilter();this.enableEditModeButton();var logGroupDOM=this.createGroup(group);var dayIndex=group.date;var container=this.callLogContainer;var previousLogGroup=document.getElementById(group.id);if(previousLogGroup){var parent=previousLogGroup.parentNode;parent.removeChild(previousLogGroup);this.insertInSection(logGroupDOM,parent);return logGroupDOM;}
var groupSelector='[data-timestamp="'+dayIndex+'"]';var sectionExists=container.querySelector(groupSelector);if(sectionExists){var section=sectionExists.getElementsByTagName('ol')[0];this.insertInSection(logGroupDOM,section);return logGroupDOM;}

View file

@ -6,6 +6,12 @@
function labels(){document.getElementById('browse').textContent=text('Ouvrir le catalogue','Open catalogue');document.getElementById('open').textContent=text('Ouvrir','Open');document.getElementById('install').textContent=text('Installer','Install');document.getElementById('cancel').textContent=text('Annuler','Cancel');}
function loadFailed(){status(text('Catalogue inaccessible. Vérifiez la connexion Internet, puis réessayez.','Catalogue unavailable. Check your Internet connection, then try again.'));document.getElementById('open').hidden=true;}
frame.addEventListener('mozbrowsererror',loadFailed);
frame.addEventListener('mozbrowserclose',()=>{
if(busy)return;
status(text('Le catalogue est fermé. Vous pouvez le rouvrir ci-dessous.',
'The catalogue is closed. You can reopen it below.'));
document.getElementById('open').hidden=true;
});
function browse(){labels();message.hidden=true;dialog.hidden=true;frame.style.visibility='visible';frame.src='https://vulpes-os.org/marketplace/?mode=app&lang='+(fr()?'fr':'en');}
function status(value){frame.style.visibility='hidden';message.hidden=false;document.getElementById('status').textContent=value;}
async function prepare(url){

File diff suppressed because one or more lines are too long

View file

@ -265,8 +265,7 @@ var CallLog = {
// If the contacts cache is not valid, we retrieve the contacts information
// directly from the contacts API and update the DOM accordingly.
if (!this._contactCache) {
this.updateListWithContactInfo(null, null, phoneNumbers,
logGroupContainer);
this.updateListWithContactInfo(null, null, logGroupContainer);
}
},

View file

@ -9,10 +9,9 @@ var LayoutDictionaryDownloader = function(imEngineId, dictFilename) {
this._xhr = null;
};
// Remote URLs to download the data. We require the remote server to have
// CORS turned on since we are not asking for SystemXHR permission.
// Dictionaries ship with Vulpes so adding a layout also works offline.
LayoutDictionaryDownloader.prototype.REMOTE_URL =
'https://fxos.cdn.mozilla.net/dictionaries/%imEngineId/%ver/%dictFilename';
'/js/imes/%imEngineId/dictionaries/%dictFilename';
// If the data schema changes in an incompatible way, we must bump the
// version-to-download here.

View file

@ -6,6 +6,12 @@
function labels(){document.getElementById('browse').textContent=text('Ouvrir le catalogue','Open catalogue');document.getElementById('open').textContent=text('Ouvrir','Open');document.getElementById('install').textContent=text('Installer','Install');document.getElementById('cancel').textContent=text('Annuler','Cancel');}
function loadFailed(){status(text('Catalogue inaccessible. Vérifiez la connexion Internet, puis réessayez.','Catalogue unavailable. Check your Internet connection, then try again.'));document.getElementById('open').hidden=true;}
frame.addEventListener('mozbrowsererror',loadFailed);
frame.addEventListener('mozbrowserclose',()=>{
if(busy)return;
status(text('Le catalogue est fermé. Vous pouvez le rouvrir ci-dessous.',
'The catalogue is closed. You can reopen it below.'));
document.getElementById('open').hidden=true;
});
function browse(){labels();message.hidden=true;dialog.hidden=true;frame.style.visibility='visible';frame.src='https://vulpes-os.org/marketplace/?mode=app&lang='+(fr()?'fr':'en');}
function status(value){frame.style.visibility='hidden';message.hidden=false;document.getElementById('status').textContent=value;}
async function prepare(url){

View file

@ -86,7 +86,16 @@ define(function(require) {
var path = pathElement ? pathElement.dataset.path : null;
var panelFuncLoaded = function(panelFunc) {
var panel = panelFunc();
var panel;
try {
panel = panelFunc();
if (!panel || typeof panel.beforeShow !== 'function') {
throw new TypeError('Invalid settings panel: ' + panelId);
}
} catch (error) {
if (callback) callback(null, error);
return;
}
_panelCache[panelId] = panel;
if (callback) {
callback(panel);
@ -97,6 +106,8 @@ define(function(require) {
require([path], function(panelFunc) {
// Create a new panel object for static panels.
panelFuncLoaded(panelFunc ? panelFunc : SettingsPanel);
}, function(error) {
if (callback) callback(null, error);
});
} else {
panelFuncLoaded(SettingsPanel);

View file

@ -187,7 +187,12 @@ define(function(require) {
}
_loadPanel(panelId, function() {
PanelCache.get(panelId, function(panel) {
PanelCache.get(panelId, function(panel, loadError) {
if (loadError || !panel) {
console.error('Settings panel ' + panelId + ':', loadError);
callback(loadError || new Error('Missing settings panel: ' + panelId));
return;
}
var newPanelElement = document.getElementById(panelId);
var currentPanelId =
_currentNavigation && _currentNavigation.panelId;

View file

@ -408,14 +408,13 @@
var appStarter = AppStarter();
if (document.readyState !== 'loading') {
// Deferred Gaia components contain UMD wrappers. Finish loading them before
// Alameda starts, otherwise an anonymous component can become a panel module.
if (document.readyState === 'complete') {
appStarter.start();
} else {
document.addEventListener('readystatechange', function readyStateChange() {
if (document.readyState == 'interactive') {
document.removeEventListener('readystatechange', readyStateChange);
appStarter.start();
}
});
document.addEventListener('DOMContentLoaded', function() {
appStarter.start();
}, { once: true });
}
})();

View file

@ -55,8 +55,15 @@
ime.dispatchEvent(new Event('inputcontextchange'));
};
Object.defineProperty(navigator,'mozInputMethod',{value:ime});
addEventListener('vulpes-service-event',({detail})=>{if(detail.type==='input-focus')setState(detail.data);});
call('input.request',{operation:'state'}).then(setState).catch(console.error);
let focusReceived=false;
addEventListener('vulpes-service-event',({detail})=>{
if(detail.type==='input-focus') {focusReceived=true;setState(detail.data);}
});
// A focus event can arrive while the initial state request is in flight.
// Its older snapshot must not clear or reopen the newer input context.
call('input.request',{operation:'state'}).then(state=>{
if(!focusReceived)setState(state);
}).catch(console.error);
window.resizeTo=(_width,height)=>call('input.request',{operation:'height',height}).catch(console.error);
}
if(app==='system') {

View file

@ -103,3 +103,25 @@
background-color: var(--vulpes-surface) !important;
color: var(--vulpes-text) !important;
}
/* Keep the message and its surface paired, including lazily loaded Gaia dialogs. */
:root #vulpes-gaia-notice,
:root #vulpes-gaia-notice > section,
:root #vulpes-gaia-notice > section > p {
background-color: #f4f4f4 !important;
color: #202b33 !important;
}
:root[data-vulpes-theme="dark"] #vulpes-gaia-notice,
:root[data-vulpes-theme="dark"] #vulpes-gaia-notice > section,
:root[data-vulpes-theme="dark"] #vulpes-gaia-notice > section > p {
background-color: var(--vulpes-surface) !important;
color: var(--vulpes-text) !important;
}
:root[data-vulpes-app="settings"] p[data-vulpes-unavailable] {
background-color: #f4f4f4 !important;
color: #202b33 !important;
}
:root[data-vulpes-theme="dark"][data-vulpes-app="settings"] p[data-vulpes-unavailable] {
background-color: var(--vulpes-surface) !important;
color: var(--vulpes-text) !important;
}

File diff suppressed because one or more lines are too long

View file

@ -223,10 +223,124 @@ break;}
var cset={};cset[key]=value;_settings.createLock().set(cset);}};});define('modules/settings_panel',['require','modules/panel','modules/settings_cache','modules/panel_utils'],function(require){'use strict';var Panel=require('modules/panel');var SettingsCache=require('modules/settings_cache');var PanelUtils=require('modules/panel_utils');var _emptyFunc=function panel_emptyFunc(){};var SettingsPanel=function ctor_SettingsPanel(options){var _panel=null;var _settingsChangeHandler=function(event){PanelUtils.onSettingsChange(_panel,event);};var _addListeners=function panel_addListeners(panel){if(!panel){return;}
SettingsCache.addEventListener('settingsChange',_settingsChangeHandler);panel.addEventListener('change',PanelUtils.onInputChange);panel.addEventListener('click',PanelUtils.onLinkClick);};var _removeListeners=function panel_removeListeners(panel){if(!panel){return;}
SettingsCache.removeEventListener('settingsChange',_settingsChangeHandler);panel.removeEventListener('change',PanelUtils.onInputChange);panel.removeEventListener('click',PanelUtils.onLinkClick);};options=options||{};options.onInit=options.onInit||_emptyFunc;options.onUninit=options.onUninit||_emptyFunc;options.onShow=options.onShow||_emptyFunc;options.onHide=options.onHide||_emptyFunc;options.onBeforeShow=options.onBeforeShow||_emptyFunc;options.onBeforeHide=options.onBeforeHide||_emptyFunc;return Panel({onInit:function(panel,initOptions){if(!panel){return;}
_panel=panel;PanelUtils.activate(panel);return options.onInit(panel,initOptions);},onUninit:function(){_removeListeners(_panel);_panel=null;options.onUninit();},onShow:function(panel,showOptions){return options.onShow(panel,showOptions);},onHide:function(){_removeListeners(_panel);return options.onHide();},onBeforeShow:function(panel,beforeShowOptions){PanelUtils.preset(panel);_addListeners(panel);return options.onBeforeShow(panel,beforeShowOptions);},onBeforeHide:function(){return options.onBeforeHide();}});};return SettingsPanel;});define('modules/panel_cache',['require','modules/settings_panel','shared/lazy_loader'],function(require){'use strict';var SettingsPanel=require('modules/settings_panel');var LazyLoader=require('shared/lazy_loader');var _panelCache={};var _panelStylesheetsLoaded=false;var _loadPanelStylesheetsIfNeeded=function loadPanelCSS(){if(_panelStylesheetsLoaded){return;}
LazyLoader.load(['shared/style/action_menu.css','shared/style/confirm.css','shared/style/progress_activity.css','shared/elements/gaia-icons/bidi-helper.css','shared/elements/gaia_buttons/script.js','shared/elements/gaia_confirm/script.js','style/homescreens.css','style/apps.css','style/screen_lock.css','style/simcard.css','style/updates.css','style/downloads.css','style/developer_service_workers.css'],function callback(){_panelStylesheetsLoaded=true;});};navigator.addIdleObserver({time:3,onidle:_loadPanelStylesheetsIfNeeded});return{reset:function spc_reset(){_panelCache={};_panelStylesheetsLoaded=false;},get:function spc_get(panelId,callback){if(!panelId&&!callback){return;}
if(panelId!=='root'){_loadPanelStylesheetsIfNeeded();}
var cachedPanel=_panelCache[panelId];if(cachedPanel){if(callback){callback(cachedPanel);}}else{var panelElement=document.getElementById(panelId);if(panelElement){var pathElement=panelElement.querySelector('panel');var path=pathElement?pathElement.dataset.path:null;var panelFuncLoaded=function(panelFunc){var panel=panelFunc();_panelCache[panelId]=panel;if(callback){callback(panel);}};if(path){require([path],function(panelFunc){panelFuncLoaded(panelFunc?panelFunc:SettingsPanel);});}else{panelFuncLoaded(SettingsPanel);}}else{if(callback){callback(null);}}}}};});var ScreenLayout={defaultQueries:{tiny:'(max-width: 767px)',small:'(min-width: 768px) and (max-width: 991px)',medium:'(min-width: 992px) and (max-width: 1200px)',large:'(min-width: 1201px)',hardwareHomeButton:window.VulpesCompat?'(min-width: 0px)':'(-moz-physical-home-button)'},init:function sl_init(){this.queries=(function(qs){var result={};for(var key in qs){result[key]=window.matchMedia(qs[key]);}
_panel=panel;PanelUtils.activate(panel);return options.onInit(panel,initOptions);},onUninit:function(){_removeListeners(_panel);_panel=null;options.onUninit();},onShow:function(panel,showOptions){return options.onShow(panel,showOptions);},onHide:function(){_removeListeners(_panel);return options.onHide();},onBeforeShow:function(panel,beforeShowOptions){PanelUtils.preset(panel);_addListeners(panel);return options.onBeforeShow(panel,beforeShowOptions);},onBeforeHide:function(){return options.onBeforeHide();}});};return SettingsPanel;});define('modules/panel_cache',["require", "modules/settings_panel", "shared/lazy_loader"],function(require) {
'use strict';
var SettingsPanel = require('modules/settings_panel');
var LazyLoader = require('shared/lazy_loader');
var _panelCache = {};
var _panelStylesheetsLoaded = false;
// experiment result shows
// load all related styles once is time saver
var _loadPanelStylesheetsIfNeeded = function loadPanelCSS() {
if (_panelStylesheetsLoaded) {
return;
}
LazyLoader.load(['shared/style/action_menu.css',
'shared/style/confirm.css',
'shared/style/progress_activity.css',
'shared/elements/gaia-icons/bidi-helper.css',
'shared/elements/gaia_buttons/script.js',
'shared/elements/gaia_confirm/script.js',
'style/homescreens.css',
// Bug 1210284 - disabling achievements until they are on
// the roadmap again or completely removed.
// 'style/achievements.css',
'style/apps.css',
'style/screen_lock.css',
'style/simcard.css',
'style/updates.css',
'style/downloads.css',
'style/developer_service_workers.css'],
function callback() {
_panelStylesheetsLoaded = true;
});
};
// load styles in idle time after document loaded
navigator.addIdleObserver({
time: 3,
onidle: _loadPanelStylesheetsIfNeeded
});
return {
// this is for unit test
reset: function spc_reset() {
_panelCache = {};
_panelStylesheetsLoaded = false;
},
/**
* Get the panel module of a specified id. If there is no corresponding
* panel module of the id, it returns SettingsPanel.
*
* @alias module:PanelCache#get
* @param {String} panelId
* The id of the to be loaded panel.
* @param {Function} callback
* The function to be called when the panel is loaded.
*/
get: function spc_get(panelId, callback) {
if (!panelId && !callback) {
return;
}
if (panelId !== 'root') {
_loadPanelStylesheetsIfNeeded();
}
var cachedPanel = _panelCache[panelId];
if (cachedPanel) {
if (callback) {
callback(cachedPanel);
}
} else {
// Get the path of the panel creation function
var panelElement = document.getElementById(panelId);
if (panelElement) {
var pathElement = panelElement.querySelector('panel');
var path = pathElement ? pathElement.dataset.path : null;
var panelFuncLoaded = function(panelFunc) {
var panel;
try {
panel = panelFunc();
if (!panel || typeof panel.beforeShow !== 'function') {
throw new TypeError('Invalid settings panel: ' + panelId);
}
} catch (error) {
if (callback) callback(null, error);
return;
}
_panelCache[panelId] = panel;
if (callback) {
callback(panel);
}
};
if (path) {
require([path], function(panelFunc) {
// Create a new panel object for static panels.
panelFuncLoaded(panelFunc ? panelFunc : SettingsPanel);
}, function(error) {
if (callback) callback(null, error);
});
} else {
panelFuncLoaded(SettingsPanel);
}
} else {
if (callback) {
callback(null);
}
}
}
}
};
});
var ScreenLayout={defaultQueries:{tiny:'(max-width: 767px)',small:'(min-width: 768px) and (max-width: 991px)',medium:'(min-width: 992px) and (max-width: 1200px)',large:'(min-width: 1201px)',hardwareHomeButton:window.VulpesCompat?'(min-width: 0px)':'(-moz-physical-home-button)'},init:function sl_init(){this.queries=(function(qs){var result={};for(var key in qs){result[key]=window.matchMedia(qs[key]);}
return result;})(this.defaultQueries);},_isOnRealDevice:undefined,isOnRealDevice:function sl_isOnRealDevice(){if(typeof(this._isOnRealDevice)!=='undefined'){return this._isOnRealDevice;}
if(window.innerWidth===screen.availWidth){this._isOnRealDevice=true;}else{this._isOnRealDevice=false;}
return this._isOnRealDevice;},getCurrentLayout:function sl_getCurrentLayout(type){if(type===undefined){for(var name in this.defaultQueries){if(this.queries[name]&&this.queries[name].matches){return name;}}}
@ -443,7 +557,12 @@ define('modules/settings_service',["require", "modules/page_transitions", "modul
}
_loadPanel(panelId, function() {
PanelCache.get(panelId, function(panel) {
PanelCache.get(panelId, function(panel, loadError) {
if (loadError || !panel) {
console.error('Settings panel ' + panelId + ':', loadError);
callback(loadError || new Error('Missing settings panel: ' + panelId));
return;
}
var newPanelElement = document.getElementById(panelId);
var currentPanelId =
_currentNavigation && _currentNavigation.panelId;

View file

@ -1,15 +1,15 @@
{
"gaia": "2.7.0",
"channel": "preview",
"revision": 20,
"androidVersionCode": 20,
"revision": 21,
"androidVersionCode": 21,
"geckoview": "156.0.20260921121718",
"engineAlignment": {
"family": "156",
"patchParity": false,
"reason": "Firefox desktop 156.0.1; Mozilla's GeckoView release repository currently offers 156.0 only. Keep the desktop patch and report actual engine versions."
},
"version": "2.7-preview.20",
"version": "2.7-preview.21",
"adapters": {
"desktop": "0.1.0-dev",
"android": "0.1.0-dev"

View file

@ -3,10 +3,10 @@ import assert from 'node:assert/strict';
import vm from 'node:vm';
import {readFileSync} from 'node:fs';
const source=readFileSync(new URL('../android/extension/content.js',import.meta.url),'utf8');
async function setup(enabled=true) {
const sent=[],events=new Map();let receive;
async function setup(enabled=true,{defer=false,activeElement}={}) {
const sent=[],events=new Map(),timers=[];let receive,disconnect;
const page={navigator:{},dispatchEvent(){}};
const port={postMessage:m=>sent.push(m),onMessage:{addListener:fn=>receive=fn},onDisconnect:{addListener(){}}};
const port={postMessage:m=>sent.push(m),onMessage:{addListener:fn=>receive=fn},onDisconnect:{addListener:fn=>disconnect=fn}};
class Editor {
constructor(){this.editing=false;}
focus(e){this.inputElement=e;}
@ -14,14 +14,14 @@ async function setup(enabled=true) {
edit(){return this.state();}
}
const context={InputEditor:Editor,browser:{runtime:{connect:()=>port}},console,
location:{port:'18765',hostname:'sms.localhost'},document:{hidden:false},
location:{port:'18765',hostname:'sms.localhost'},document:{hidden:false,activeElement,hasFocus:()=>true},
window:{wrappedJSObject:page,Promise,Error,Event,CustomEvent:class extends Event{constructor(name,p){super(name);this.detail=p.detail;}}},
cloneInto:x=>x,exportFunction:(fn,obj,p)=>{obj[p.defineAs]=fn;},
addEventListener:(name,fn)=>events.set(name,fn),setTimeout,clearTimeout};
addEventListener:(name,fn)=>events.set(name,fn),setTimeout:fn=>timers.push(fn),clearTimeout(){}};
vm.runInNewContext(source,context);
receive({id:sent[0].id,reply:{result:enabled}});
if(!defer) receive({id:sent[0].id,reply:{result:enabled}});
await new Promise(resolve=>setImmediate(resolve));
return {sent,events,page,receive,context};
return {sent,events,page,receive,context,timers,disconnect};
}
function field(mode='numeric') {
const attrs=new Map([['inputmode',mode]]);
@ -51,3 +51,54 @@ test('Android keyboard remains the default and hidden Gaia restores the editor m
assert.equal(e.getAttribute('inputmode'),'numeric');
assert.equal(s.sent.at(-1).inputFocus,null);
});
test('a field focused before the preference arrives activates Gaia without another tap',async()=>{
const e=field(),s=await setup(true,{defer:true,activeElement:e});
s.events.get('focusin')({target:e});
assert.equal(s.sent.length,1);
s.receive({id:s.sent[0].id,reply:{result:true}});
await new Promise(resolve=>setImmediate(resolve));
assert.equal(e.getAttribute('inputmode'),'none');
assert.equal(s.sent.at(-1).inputFocus.inputMode,'numeric');
});
test('an old preference reply cannot undo a newer keyboard toggle',async()=>{
const e=field(),s=await setup(false,{defer:true,activeElement:e});
s.receive({event:{type:'input-mode',data:{enabled:true}}});
s.receive({id:s.sent[0].id,reply:{result:false}});
await new Promise(resolve=>setImmediate(resolve));
assert.equal(s.page.VulpesNativeInput,true);
assert.equal(e.getAttribute('inputmode'),'none');
});
test('returning to an app restores its focused Gaia editor',async()=>{
const e=field(),s=await setup(true,{activeElement:e});
s.context.document.hidden=true;s.events.get('visibilitychange')();
assert.equal(s.sent.at(-1).inputFocus,null);
s.context.document.hidden=false;s.events.get('visibilitychange')();
assert.equal(s.sent.at(-1).inputFocus.inputMode,'numeric');
assert.equal(e.getAttribute('inputmode'),'none');
});
test('bridge reconnection rereads the preference and restores the active editor',async()=>{
const e=field(),s=await setup(true,{activeElement:e});
s.disconnect();
assert.equal(e.getAttribute('inputmode'),'numeric');
assert.equal(s.page.VulpesNativeInput,false);
s.timers.at(-1)();
const request=s.sent.at(-1);
assert.equal(request.request.method,'input.config');
s.receive({id:request.id,reply:{result:true}});
await new Promise(resolve=>setImmediate(resolve));
assert.equal(e.getAttribute('inputmode'),'none');
assert.equal(s.sent.at(-1).inputFocus.inputMode,'numeric');
});
test('a background app cannot reclaim the keyboard when Android resumes',async()=>{
const e=field(),s=await setup(true,{activeElement:e});
s.context.document.hidden=true;s.events.get('visibilitychange')();
s.context.document.hidden=false;s.context.document.hasFocus=()=>false;
s.events.get('visibilitychange')();
assert.equal(s.sent.at(-1).inputFocus,null);
assert.equal(e.getAttribute('inputmode'),'numeric');
});

View file

@ -0,0 +1,59 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {Contacts} from '../host/Contacts.sys.mjs';
import {Messages} from '../host/Messages.sys.mjs';
const source=readFileSync('android/extension/personal-data.mjs','utf8')
.replace("'./Contacts.mjs'",JSON.stringify(new URL('../host/Contacts.sys.mjs',import.meta.url).href))
.replace("'./Messages.mjs'",JSON.stringify(new URL('../host/Messages.sys.mjs',import.meta.url).href));
const {AndroidPersonalData,authorizePersonal,smsFromAndroid}=await import('data:text/javascript;base64,'+Buffer.from(source).toString('base64'));
const app={id:'communications',manifest:{permissions:{contacts:{access:'readwrite'}}}};
const smsApp={id:'sms',manifest:{permissions:{sms:{},contacts:{access:'readonly'}}}};
async function setup(){
const data=new Map([['vulpes-private-contacts',{revision:1,contacts:[{id:'local-1',name:['Local']}]}],['vulpes-private-messages',[{id:100,threadId:200,timestamp:1,body:'Local',delivery:'sent',receiver:'5550100',type:'sms',read:true}]]]);
const storage={run:async(name,op,args)=>{if(op==='get')return {value:structuredClone(data.get(name))};data.set(name,structuredClone(args[0]));}};
const contacts=new Contacts(storage,()=>{});await contacts.init();
const calls=[];let allowed=true;
const service=new AndroidPersonalData(async p=>{calls.push(p);if(p.operation==='status')return {contacts:allowed,sms:allowed};return {rows:p.kind==='contacts'?[{_id:1,display_name:'Android',tel:[{value:'5550199'}]}]:[{_id:1,thread_id:2,address:'5550199',body:'Android SMS',date:2,type:1,read:0,status:-1}],next:null};},contacts,new Messages(storage),storage);
return {service,data,calls,revoke(){allowed=false;}};
}
test('native contact reads preserve the local address book and search phone numbers',async()=>{
const s=await setup();const rows=await s.service.contactRequest(app,{operation:'all'});
assert.deepEqual(rows.map(r=>r.id),['local-1','android:1']);
assert.equal((await s.service.contactRequest(app,{operation:'find',options:{filterBy:['tel'],filterOp:'match',filterValue:'0199'}}))[0].id,'android:1');
assert.equal(s.data.get('vulpes-private-contacts').contacts.length,1);
s.revoke();assert.equal((await s.service.contactRequest(app,{operation:'all'})).length,1);
});
test('native messages and threads remain distinct from local IDs and are not copied to storage',async()=>{
const s=await setup();const rows=await s.service.messageRequest(smsApp,{operation:'getThreads'});
assert.deepEqual(rows.map(r=>r.id),[-2,200]);
const [sms]=await s.service.messageRequest(smsApp,{operation:'getMessages',args:[{threadId:-2}]});assert.equal(sms.id,-1);assert.equal(sms.body,'Android SMS');
assert.equal(s.data.get('vulpes-private-messages').length,1);
s.revoke();assert.equal((await s.service.messageRequest(smsApp,{operation:'getThreads'})).length,1);
});
test('Android writes never fall through to the local store or report false success',async()=>{
const s=await setup();
for(const params of [{operation:'save',contact:{id:'android:1'}},{operation:'remove',id:'android:1'},{operation:'clear'}]) await assert.rejects(s.service.contactRequest(app,params),/READ_ONLY/);
for(const params of [{operation:'delete',args:[[100,-1]]},{operation:'markMessageRead',args:[-1,true]}])await assert.rejects(s.service.messageRequest(smsApp,params),/READ_ONLY/);
assert.equal(s.data.get('vulpes-private-messages').length,1);
});
test('untrusted installed apps cannot reuse the Android permission granted to Gaia',async()=>{
const s=await setup();
assert.equal((await s.service.contactRequest({...app,installed:true},{operation:'all'})).length,1);
assert.equal((await s.service.messageRequest({...smsApp,installed:true},{operation:'getMessages'})).length,1);
assert.equal(s.calls.length,0);
assert.throws(()=>authorizePersonal({...app,installed:true},'contacts','page'),/PERMISSION/);
assert.throws(()=>authorizePersonal(smsApp,'calls','page'),/PERMISSION/);
assert.throws(()=>authorizePersonal(app,'contacts','permission'),/PERMISSION/);
assert.doesNotThrow(()=>authorizePersonal({id:'settings'},'sms','permission'));
});
test('provider errors and stalled page cursors are surfaced, not presented as an empty book',async()=>{
const s=await setup();let n=0;
s.service.native=async p=>p.operation==='status'?{contacts:true}:{rows:[],next:++n===1?1:1};
await assert.rejects(s.service.rows('contacts'),/INVALID_CURSOR/);
s.service.native=async()=>{throw Error('denied');};await assert.rejects(s.service.rows('contacts'),/denied/);
});
test('SMS mapping does not turn pending or failed sends into delivered messages',()=>{
for(const [type,delivery] of [[1,'received'],[2,'sent'],[4,'sending'],[5,'error'],[6,'sending']])assert.equal(smsFromAndroid({_id:1,thread_id:1,type}).delivery,delivery);
assert.throws(()=>smsFromAndroid({_id:Infinity,thread_id:1}),/INVALID_ANDROID_ID/);
});

View file

@ -0,0 +1,85 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {readFileSync} from 'node:fs';
function setup(app,path,call){
const events=new Map(),notices=[];
const context={console,URL,Map,Set,Number,JSON,Date,Promise,queueMicrotask,setTimeout,clearTimeout,setInterval,clearInterval,
location:{hostname:app+'.localhost',pathname:path},navigator:{language:'en'},localStorage:{removeItem(){}},
document:{hidden:false,documentElement:{lang:'en'},readyState:'complete',addEventListener:(type,fn)=>events.set('document-'+type,fn),querySelectorAll(){return[];}},
MutationObserver:class{observe(){}},addEventListener:(type,fn)=>events.set(type,fn),
VulpesCompat:{call},VulpesGaiaUI:{notice:value=>notices.push(value)}};
context.window=context;vm.runInNewContext(readFileSync('android/ui/android-ui.js','utf8'),context);
return {context,events,notices};
}
test('native call rows use Gaia grouping and expose details without changing the local DB',async()=>{
const s=setup('communications','/dialer/index.html',async(_method,p)=>p.operation==='status'?{calls:true}:[
{_id:1,date:1000,number:'5550199',duration:42,type:2},
{_id:2,date:2000,number:'5550199',duration:0,type:2}]);
let localReads=0;
s.context.CallLogDBManager={getGroupList(){localReads++;},getGroup(){localReads++;},getGroupAtPosition(){localReads++;},
_getGroupId:r=>[0,r.number,r.type],_getGroupObject:g=>({...g,id:g.id.join('-'),type:g.id[2]})};
const db=s.context.CallLogDBManager;
const rows=await new Promise(resolve=>db.getGroupList(resolve,'lastEntryDate',true,false));
assert.equal(rows.length,1);assert.equal(rows[0].retryCount,2);assert.equal(rows[0].calls[1].duration,42000);
assert.equal((await db.getGroup('5550199',1000,'dialing')).calls.length,2);assert.equal(localReads,0);
const streamed=[];
await new Promise(resolve=>db.getGroupList(c=>{if(!c.value)return resolve();streamed.push(c.value);c.continue();},'lastEntryDate',true,true));
assert.equal(streamed.length,1);
});
test('SMS provider change refreshes the open conversation without reloading or touching the draft',async()=>{
const inserted=[];
const s=setup('sms','/index.html',async()=>[{id:-1,threadId:-2,body:'Received',timestamp:1000}]);
s.context.Navigation={isCurrentPanel:name=>name==='thread'};
s.context.ConversationView={activeThread:{id:-2},appendMessage:async message=>inserted.push(message)};
s.context.Compose={getText:()=> 'Unsent draft'};
s.context.document.getElementById=()=>null;
s.context.location.reload=()=>{throw Error('draft lost');};
s.events.get('vulpes-service-event')({detail:{type:'android-data-changed',data:{kind:'sms'}}});
await new Promise(resolve=>setImmediate(resolve));
assert.equal(inserted.length,1);assert.equal(inserted[0].body,'Received');
assert.equal(s.context.Compose.getText(),'Unsent draft');
});
test('hidden call log refreshes contact names when shown after Android access changes',()=>{
const s=setup('communications','/dialer/index.html',async()=>({}));
let updates=0;
s.context.CallLog={sticky:{},_contactCache:true,updateListWithContactInfo(){updates++;}};
s.context.document.hidden=true;
s.events.get('vulpes-service-event')({detail:{type:'android-data-changed',data:{kind:'contacts'}}});
assert.equal(updates,0);
s.context.document.hidden=false;
s.events.get('visibilitychange')();
assert.equal(updates,1);
assert.equal(s.context.CallLog._contactCache,false);
});
test('Android contact HTML caches are disabled before Gaia can apply stale names',async()=>{
const s=setup('communications','/contacts/index.html',async()=>({}));
try {
let writes=0;
s.context.Cache={get enabled(){return true;},get active(){return true;},set firstChunk(value){writes++;},apply:async()=> 'stale names'};
assert.equal(s.context.Cache.enabled,false);
assert.equal(s.context.Cache.active,false);
s.context.Cache.firstChunk={cache:'Android names'};
assert.equal(writes,0);
await assert.rejects(s.context.Cache.apply());
} finally{s.events.get('pagehide')();}
});
test('revoked native contact details close when the hidden app becomes visible',async()=>{
const s=setup('communications','/contacts/index.html',async()=>[]);
try {
let closed=0;
s.context.MainNavigation={currentView:()=> 'view-contact-details'};
s.context.Contacts={goBack(){closed++;}};
s.events.get('document-click')({target:{closest:()=>({dataset:{uuid:'android:1'}})}});
s.context.document.hidden=true;
s.events.get('vulpes-service-event')({detail:{type:'android-data-changed',data:{kind:'contacts'}}});
assert.equal(closed,0);
s.context.document.hidden=false;
s.events.get('visibilitychange')();
await new Promise(resolve=>setImmediate(resolve));
assert.equal(closed,1);
} finally{s.events.get('pagehide')();}
});

View file

@ -0,0 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {readFileSync} from 'node:fs';
for (const file of ['gaia/apps/communications/dialer/js/call_log.js',
'assets/webapps/communications.gaiamobile.org/dialer/js/call_log.js']) {
test(`call log resolves contacts in its newly rendered section: ${file}`, () => {
const context = {navigator:{mozContacts:{}},window:{}};
vm.runInNewContext(readFileSync(file,'utf8'),context);
const log = context.CallLog, items = [], lookedUp = [];
const list = {appendChild:row=>items.push(row),querySelectorAll:()=>items};
log.createSectionForGroup=()=>({getElementsByTagName:()=>[list]});
log.createGroup=group=>({dataset:{phoneNumber:group.number}});
log.callLogContainer={appendChild(){}};
log._updateContact=(_node,number)=>lookedUp.push(number);
log.renderChunk([{number:'5550199'}]);
assert.deepEqual(lookedUp,['5550199']);
});
}

View file

@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {readFileSync} from 'node:fs';
const root=new URL('../',import.meta.url);
const source=readFileSync(new URL('gaia/apps/keyboard/js/settings/layout_dictionary_downloader.js',root),'utf8');
test('every offered Latin dictionary is present and has a valid header',()=>{
const layouts=JSON.parse(readFileSync(new URL('assets/webapps/keyboard.gaiamobile.org/js/settings/layouts.json',root)));
for(const name of new Set(layouts.filter(l=>l.imEngineId==='latin'&&l.dictFilename).map(l=>l.dictFilename))){
const data=readFileSync(new URL('gaia/apps/keyboard/js/imes/latin/dictionaries/'+name,root));
assert.equal(data.subarray(0,8).toString(),'FxOSDICT',name);
}
});
test('adding a language reads and verifies its local dictionary',async()=>{
let requested,aborted=false;
class XHR {
open(method,url){requested=url;}
send(){const b=readFileSync(new URL('gaia/apps/keyboard'+requested,root));this.response=b.buffer.slice(b.byteOffset,b.byteOffset+b.byteLength);this.onloadend();}
abort(){aborted=true;}
}
const window={};vm.runInNewContext(source,{window,XMLHttpRequest:XHR,ArrayBuffer,Uint8Array,Promise});
const data=await new window.LayoutDictionaryDownloader('latin','it.dict').load();
assert.equal(requested,'/js/imes/latin/dictionaries/it.dict');assert.ok(data.byteLength>8);assert.equal(aborted,false);
});

View file

@ -0,0 +1,40 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {readFileSync} from 'node:fs';
const source=readFileSync(new URL('../host/gaia-input.js',import.meta.url),'utf8');
function keyboard() {
const listeners=new Map();let reply;
const parent={postMessage(){}};
const context={VulpesNativeInput:true,location:{hostname:'keyboard.localhost'},
VulpesCompat:{call:()=>new Promise(resolve=>reply=resolve)},
navigator:{},document:{readyState:'loading'},parent,console,Event,EventTarget,
CustomEvent:class extends Event {constructor(name,options){super(name);this.detail=options.detail;}},
addEventListener(name,fn){listeners.set(name,fn);},dispatchEvent(){}};
context.window=context;
vm.runInNewContext(source,context);
return {
ime:context.navigator.mozInputMethod,
state:value=>listeners.get('vulpes-service-event')({detail:{type:'input-focus',data:value}}),
reply:value=>reply(value),
viewport:()=>listeners.get('message')({source:parent,origin:'http://system.localhost:8765',
data:{type:'vulpes-keyboard-viewport',width:360,height:740}})
};
}
test('an old empty snapshot cannot clear a newly focused keyboard',async()=>{
const k=keyboard();k.viewport();k.state({id:2,text:'current'});k.reply(null);
await new Promise(resolve=>setImmediate(resolve));
assert.equal(k.ime.inputcontext.text,'current');
});
test('an old active snapshot cannot reopen a dismissed keyboard',async()=>{
const k=keyboard();k.viewport();k.state(null);k.reply({id:1,text:'old'});
await new Promise(resolve=>setImmediate(resolve));
assert.equal(k.ime.inputcontext,null);
});
test('focus received before viewport measurement survives a stale initial reply',async()=>{
const k=keyboard();k.state({id:3,text:'pending'});k.reply(null);
await new Promise(resolve=>setImmediate(resolve));
k.viewport();
assert.equal(k.ime.inputcontext.text,'pending');
});

View file

@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {readFileSync} from 'node:fs';
const source=readFileSync(new URL('../gaia/apps/marketplace/marketplace.js',import.meta.url),'utf8');
function element(){return Object.assign(new EventTarget(),{style:{},hidden:false,textContent:''});}
async function setup() {
const nodes=Object.fromEntries(['catalogue','message','confirm','open','browse','install','cancel','status','title','question','permissions'].map(id=>[id,element()]));
let poll,download=null,finish;
const context={document:{getElementById:id=>nodes[id]},navigator:{language:'en'},console,
addEventListener(){},setInterval:fn=>poll=fn,VulpesCompat:{call:async method=>{
if(method==='locale.current')return 'en';
if(method==='marketplace.poll'){const url=download;download=null;return url;}
if(method==='marketplace.prepare')return new Promise(resolve=>finish=resolve);
}}};
await vm.runInNewContext(source,context);
return {nodes,poll,setDownload:()=>download='https://vulpes-os.org/marketplace/index.php?download=1',finish:()=>finish({name:'Test',bytes:1024,token:'test'})};
}
test('closing the Android web view exposes a way back to the catalogue, not a blank frame',async()=>{
const {nodes:n}=await setup();
n.catalogue.dispatchEvent(new Event('mozbrowserclose'));
assert.equal(n.message.hidden,false);assert.equal(n.catalogue.style.visibility,'hidden');
assert.match(n.status.textContent,/reopen/);assert.equal(n.open.hidden,true);
n.browse.onclick();
assert.equal(n.message.hidden,true);assert.equal(n.catalogue.style.visibility,'visible');
assert.match(n.catalogue.src,/mode=app&lang=en/);
});
test('a close notification cannot erase an installation confirmation',async()=>{
const f=await setup();f.setDownload();const pending=f.poll();
await new Promise(resolve=>setImmediate(resolve));
f.nodes.catalogue.dispatchEvent(new Event('mozbrowserclose'));
assert.match(f.nodes.status.textContent,/Checking/);
f.finish();await pending;assert.equal(f.nodes.confirm.hidden,false);
});

View file

@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {readFileSync} from 'node:fs';
const source=readFileSync(new URL('../gaia/apps/settings/js/modules/panel_cache.js',import.meta.url),'utf8');
function fixture(load) {
let cache;
function require(id,ok,fail) {
if(Array.isArray(id))return load(ok,fail);
if(id==='shared/lazy_loader')return {load:(_,done)=>done()};
return ()=>({beforeShow(){}});
}
vm.runInNewContext(source,{define:factory=>cache=factory(require),navigator:{addIdleObserver(){}},
document:{getElementById:()=>({querySelector:()=>({dataset:{path:'panels/storage/panel'}})})}});
return cache;
}
for(const failure of ['dependency','constructor','invalid']) test('Panel cache reports '+failure+' failure without caching a broken panel',()=>{
let broken=true;
const expected=new Error('storage unavailable');
const cache=fixture((ok,fail)=>{
if(!broken)return ok(()=>({beforeShow(){}}));
if(failure==='dependency')return fail(expected);
ok(()=>{if(failure==='constructor')throw expected;return {};});
});
let calls=0;
cache.get('mediaStorage',(panel,error)=>{calls++;assert.equal(panel,null);assert.ok(error);});
assert.equal(calls,1);broken=false;
cache.get('mediaStorage',(panel,error)=>{assert.equal(error,undefined);assert.equal(typeof panel.beforeShow,'function');});
});

View file

@ -0,0 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {readFileSync} from 'node:fs';
const source=readFileSync(new URL('../gaia/apps/settings/js/startup.js',import.meta.url),'utf8');
const bootstrap=source.slice(source.lastIndexOf('(function() {'));
test('Settings waits for deferred components before loading AMD modules',()=>{
let starts=0;
const document=Object.assign(new EventTarget(),{readyState:'interactive'});
vm.runInNewContext(bootstrap,{document,AppStarter:()=>({start:()=>starts++})});
assert.equal(starts,0);
document.dispatchEvent(new Event('DOMContentLoaded'));assert.equal(starts,1);
document.dispatchEvent(new Event('DOMContentLoaded'));assert.equal(starts,1);
});
test('Settings starts immediately when loaded after the document',()=>{
let starts=0;
vm.runInNewContext(bootstrap,{document:{readyState:'complete'},AppStarter:()=>({start:()=>starts++})});
assert.equal(starts,1);
});

View file

@ -2,6 +2,7 @@
import copy
import importlib.util
import json
import re
from pathlib import Path
import tempfile
import unittest
@ -41,8 +42,8 @@ class ChangelogTests(unittest.TestCase):
self.assertTrue(documents['release-body.md'].startswith(self.source['releases'][self.tag]['summary']))
for lang in export.LANGUAGES:
text = documents[f'CHANGELOG.{lang}.md']
self.assertIn('## 2.7-preview.14', text)
self.assertNotIn('## 2.7-156.0.1 ', text)
self.assertIn('## 30/09/2026 : 2.7-preview.14', text)
self.assertNotIn('## 2.7-156.0.1', text)
future = copy.deepcopy(self.source['entries'][0])
future['id'] = 'future'
self.source['entries'].insert(0, future)
@ -62,16 +63,47 @@ class ChangelogTests(unittest.TestCase):
export.generate(p, Path(d), Path(d)/'site.json', Path(d)/'releases')
self.assertFalse((Path(d)/'site.json').exists())
def test_flat_entry_keeps_explicit_platforms_in_both_languages(self):
def test_grouped_entries_keep_explicit_platforms_in_both_languages(self):
for language in export.LANGUAGES:
for source, rendered in zip(self.source['entries'], self.languages[language]):
self.assertEqual(rendered['platforms'], source['metadata']['platforms'])
self.assertEqual(rendered['models'], source['metadata']['models'])
self.assertEqual(rendered['status'], source['metadata']['status'])
self.assertEqual(rendered['groups'][0]['platforms'], rendered['platforms'])
self.assertEqual({p for group in rendered['groups'] for p in group['platforms']},
set(rendered['platforms']))
def test_date_first_heading_and_platform_badges(self):
for language in export.LANGUAGES:
entry = next(e for e in self.languages[language] if e['id'] == '2.7-preview.20')
self.assertEqual(entry['id'], '2.7-preview.20')
self.assertEqual(entry['date'], '2026-10-13')
common = next(g for g in entry['groups'] if g['common'])
self.assertEqual(common['platforms'], ['pc', 'apk', 'models'])
web = next(g for g in entry['groups'] if g['name'] == 'Web')
self.assertEqual(web['platforms'], ['web'])
self.assertIn('>Web</span>', web['nameHtml'])
self.assertEqual(entry['notes'], '')
self.assertEqual(export.group_scope('![Desktop + Sargo](https://example.org/badge)')[1], ['pc', 'models'])
with self.assertRaisesRegex(ValueError, 'Unknown platform'):
export.group_scope('Unknown')
def test_tags_alphabetical_in_every_platform_group(self):
for entry in self.source['entries']:
for lang in export.LANGUAGES:
for group in re.split(r'^### ', entry['text'][lang], flags=re.M)[1:]:
tags = re.findall(r'^- !\[([^\]]+)\]', group, re.M)
self.assertTrue(tags, (entry['id'], lang))
self.assertEqual(tags, sorted(tags), (entry['id'], lang))
self.assertLessEqual(set(tags), {'Add', 'Change', 'Fix', 'Note', 'Test'})
def test_release_summary_follows_the_entry_without_copying_text(self):
tag = 'v2.7-preview.20'
next(e for e in self.source['entries'] if e['id'] == '2.7-preview.20')['text']['fr'] += '\nA new correction.\n'
docs = export.release_documents(self.source, tag)
self.assertIn('A new correction.', docs['release-body.md'])
def test_badges_are_images_with_escaped_attributes(self):
rendered = export.inline('![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) **Camera**')
rendered = export.inline('![Fix](https://example.org/badge.svg) **Camera**')
self.assertIn('<img class="cl-badge"', rendered)
self.assertIn('alt="Fix"', rendered)
self.assertIn('<strong>Camera</strong>', rendered)
@ -79,6 +111,11 @@ class ChangelogTests(unittest.TestCase):
self.assertNotIn('<img', export.inline('![unsafe](javascript:alert)'))
self.assertNotIn(' onerror="', export.inline('![\" onerror=\"bad](https://example.org/a.png)'))
def test_website_badges_do_not_require_external_images(self):
rendered = export.inline('![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square)')
self.assertEqual(rendered, '<span class="cl-badge" style="background:#52677D">Fix</span>')
self.assertNotIn('<script>', export.inline('![<script>](https://img.shields.io/badge/Fix-52677D?style=flat-square)'))
def test_invalid_platform_is_rejected(self):
self.source['entries'][0]['metadata']['platforms'] = ['unknown']
with tempfile.TemporaryDirectory() as directory:

View file

@ -2,7 +2,7 @@
"""Reject stale Android payloads and inconsistent common release metadata."""
import argparse, contextlib, hashlib, io, json, re, zipfile
from project import ROOT, release_info
from project import ROOT, release_info, SOURCE_OVERRIDES
p = argparse.ArgumentParser()
group = p.add_mutually_exclusive_group()
@ -72,6 +72,7 @@ with source as archive:
assert actual == expected.encode(), "Stale Android Gaia panel: " + path
results[app + "/" + path] = hashlib.sha256(actual).hexdigest()
for app, path, source in [
("keyboard", "gaia_build_defer_settings.js", "overrides"),
("calendar", "gaia_build_defer_index.js", "overrides"),
("calendar", "js/bundle.js", "overrides"),
("gallery", "js/metadata_scripts.js", "overrides"),
@ -94,6 +95,13 @@ with source as archive:
actual = gaia.read(app + "/" + path)
assert actual == expected, "Stale application workflow: " + app + "/" + path
results[app + "/" + path] = hashlib.sha256(actual).hexdigest()
for app in ("keyboard", "settings"):
for name in SOURCE_OVERRIDES[app]:
expected = (ROOT / "gaia/apps" / app / name).read_bytes()
if name.endswith(".js"):
expected = translate(expected.decode()).encode()
assert gaia.read(app + "/" + name) == expected, "Stale shared source: " + app + "/" + name
results[app + "/" + name] = hashlib.sha256(expected).hexdigest()
helper = "dialer/js/telephony_helper.js"
expected = translate((ROOT / "gaia/apps/communications" / helper).read_text()).encode()
assert gaia.read("communications/" + helper) == expected, "Stale call-log dial helper"

View file

@ -21,6 +21,10 @@ def inline(value):
return '<strong>' + html.escape(text[2:-2]) + '</strong>'
if text.startswith('!['):
label, url = re.fullmatch(r'!\[([^\]]+)\]\(([^)]+)\)', text).groups()
badge = re.fullmatch(r'https://img\.shields\.io/badge/[^?]+-([0-9A-Fa-f]{6})\?style=flat-square', url)
if badge:
# Website badges are text: visible offline, without image-loading delays.
return '<span class="cl-badge" style="background:#' + badge[1] + '">' + html.escape(label) + '</span>'
if url.startswith('https://'):
return '<img class="cl-badge" src="' + html.escape(url, quote=True) + '" alt="' + html.escape(label, quote=True) + '" height="20" loading="lazy" style="vertical-align:middle;width:auto">'
return html.escape(label)
@ -62,6 +66,22 @@ def parse(path):
return parse_text(path.read_text())
def group_scope(name):
"""Read platform badges as labels, not as image URLs."""
label = re.sub(r'!\[([^\]]+)\]\([^)]+\)', r'\1', name)
common = label.startswith(('Commun', 'Shared', 'Toutes les plateformes', 'All Platforms'))
if common:
return label, ['pc', 'apk', 'models'], True
scope = []
if re.search(r'\bPC\b|Desktop', label): scope.append('pc')
if 'Android' in label: scope.append('apk')
if 'Sargo' in label or 'Tundra' in label: scope.append('models')
if label == 'Web': scope.append('web')
if not scope:
raise ValueError('Unknown platform group: ' + label)
return label, scope, False
def parse_text(markdown):
entries = []
months = {name: index for names in (
@ -71,23 +91,26 @@ def parse_text(markdown):
for chunk in re.split(r'^## ', markdown, flags=re.MULTILINE)[1:]:
heading, _, body = chunk.partition('\n')
if heading.startswith(('Tenue du journal', 'Maintaining this journal')): continue
version = re.match(r'\d+\.\d+[.\w-]*', heading)
key = version.group() if version else ('development' if heading.startswith(('En développement', 'In development')) else 'history')
title = re.sub(r'^\d{2}/\d{2}/\d{4}\s*:\s*', '', heading)
version = re.match(r'\d+\.\d+[.\w-]*', title)
key = version.group() if version else ('development' if title.startswith(('En développement', 'In development')) else 'history')
kind = 'version' if version else key
stamp = re.search(r'(\d{1,2}) ([A-Za-zéû]+) (\d{4})', heading)
date = None
if stamp and stamp[2].lower() in months:
date = f'{stamp[3]}-{months[stamp[2].lower()]:02d}-{int(stamp[1]):02d}'
numeric_date = re.match(r'(\d{2})/(\d{2})/(\d{4})\s*:', heading)
if numeric_date:
date = f'{numeric_date[3]}-{numeric_date[2]}-{numeric_date[1]}'
names = re.findall(r'^### (.+)', body, re.MULTILINE)
platforms = []
if any(re.search(r'\bPC\b|Desktop', name) for name in names): platforms.append('pc')
if any('Android' in name for name in names): platforms.append('apk')
if any('Tundra' in name or 'Sargo' in name for name in names): platforms.append('models')
if not platforms:
platforms = ['pc', 'apk', 'models'] if any(name.startswith(('Commun', 'Shared')) for name in names) else ['apk']
platforms = [p for p in ('pc', 'apk', 'models', 'web')
if any(p in group_scope(name)[1] for name in names)] or ['apk']
status = 'development' if key == 'development' else 'history' if key == 'history' else 'published' if re.search(r'publi[ée]|published', heading, re.I) else 'built'
release = re.search(r'^\[Release\]\((https://[^)]+)\)', body, re.MULTILINE)
body = re.sub(r'^\[Release\]\([^)]+\)\s*', '', body, flags=re.MULTILINE)
# The website already displays the status beside the date/version.
body = re.sub(r'^!\[(?:Publié|Published|En développement|In development|APK construit · non publié|APK built · not published)\]\([^)]+\)\s*',
'', body, flags=re.MULTILINE)
parts = re.split(r'^### (.+)\n', body, flags=re.MULTILINE)
groups = []
intro = blocks(parts[0].splitlines())
@ -95,16 +118,11 @@ def parse_text(markdown):
groups.append({'name': '', 'platforms': platforms, 'common': False, 'html': intro})
intro = ''
for name, text in zip(parts[1::2], parts[2::2]):
common = name.startswith(('Commun', 'Shared'))
scope = platforms if common else []
if not common:
if re.search(r'\bPC\b|Desktop', name): scope.append('pc')
if 'Android' in name: scope.append('apk')
if 'Sargo' in name or 'Tundra' in name: scope.append('models')
if not scope: scope = ['pc']
label, scope, common = group_scope(name)
# Closing limits belong to the whole entry, not to the last group.
split = re.split(r'(?=^\*\*(?:Limites|Limitations) :)|(?=^\*\*Limitations:)', text, maxsplit=1, flags=re.MULTILINE)
groups.append({'name': name, 'platforms': scope, 'common': common, 'html': blocks(split[0].splitlines())})
groups.append({'name': label, 'nameHtml': inline(name), 'platforms': scope,
'common': common, 'html': blocks(split[0].splitlines())})
if len(split)>1: intro += blocks(split[1].splitlines())
entries.append({'id': key, 'kind': kind, 'date': date, 'title': heading, 'status': status, 'platforms': platforms,
'models': ['sargo'] if 'models' in platforms else [], 'notes': intro,
@ -148,7 +166,7 @@ def load_source(path):
require(metadata['status'] in ('development', 'history', 'built', 'published'), 'Invalid entry status: ' + key)
require(isinstance(metadata['platforms'], list) and bool(metadata['platforms']) and
len(set(metadata['platforms'])) == len(metadata['platforms']) and
set(metadata['platforms']) <= {'pc', 'apk', 'models'}, 'Invalid platforms: ' + key)
set(metadata['platforms']) <= {'pc', 'apk', 'models', 'web'}, 'Invalid platforms: ' + key)
require(isinstance(metadata['models'], list) and
all(isinstance(model, str) and re.fullmatch(r'[a-z0-9-]+', model) for model in metadata['models']) and
(not metadata['models'] or 'models' in metadata['platforms']), 'Invalid models: ' + key)
@ -162,6 +180,9 @@ def load_source(path):
# Flat changelogs keep scope independently of translated prose.
if len(item['groups']) == 1 and not item['groups'][0]['name']:
item['groups'][0]['platforms'] = metadata['platforms']
else:
require(set(metadata['platforms']) == {p for g in item['groups'] for p in g['platforms']},
'Group/entry platform mismatch: ' + key)
if item['kind'] == 'version':
require(item['id'] == key, 'Version/ID mismatch: ' + key)
# Development IDs are fixed in the source, independent of order/date.
@ -188,15 +209,19 @@ def release_documents(source, tag):
histories = {}
for lang in LANGUAGES:
title = 'Historique cumulatif' if lang == 'fr' else 'Cumulative history'
note = ('Les sections datées décrivent l’état à chaque étape. Les limites et correctifs ont évolué ; '
'consulter la synthèse de la release pour l’état final. Les chemins de documentation désignent '
'les sources locales, dont la publication est distincte de cet APK.' if lang == 'fr' else
'Dated sections describe each development stage. Limitations and fixes evolved; the release '
'summary describes the final state. Documentation paths refer to local sources; source '
'publication is separate from this APK.')
note = ('Chaque entrée décrit la situation à sa date. Consulter la synthèse de la release pour son état final.'
if lang == 'fr' else
'Each entry describes the state at its date. See the release summary for its final state.')
histories[lang] = (f'# {title} — {release["after"]} → {release["version"]}\n\n{note}\n\n' +
'\n\n'.join(e['text'][lang].strip() for e in selected) + '\n')
body = release['summary'].strip() + '\n\n'
if release.get('include_entry'):
for lang, label in [('fr', '🇫🇷 Français'), ('en', '🇬🇧 English')]:
summary = selected[0]['text'][lang].strip()
# Historical build status remains in the dated history, not in a published release's summary.
summary = re.sub(r'^!\[(?:APK construit · non publié|APK built · not published)\]\([^)]+\)\s*',
'', summary, flags=re.MULTILINE)
body += label + '\n\n' + summary + '\n\n'
for lang, label in [('fr', '🇫🇷 Historique complet, étape par étape'),
('en', '🇬🇧 Full history, stage by stage')]:
body += f'<details>\n<summary>{label}</summary>\n\n{histories[lang]}\n</details>\n\n'

View file

@ -11,6 +11,11 @@ SOURCE_OVERRIDES = {
"system": ["js/launcher.js", "js/gaia_scheduler.js", "js/keyboard_manager.js",
"js/core.js", "js/rocketbar.js"],
"communications": ["dialer/js/telephony_helper.js"],
"settings": ["js/modules/storage_helper.js", "js/startup.js"],
"keyboard": ["js/settings/layout_dictionary_downloader.js"] + [
str(path.relative_to(ROOT / "gaia/apps/keyboard"))
for path in sorted((ROOT / "gaia/apps/keyboard/js/imes/latin/dictionaries").glob("*.dict"))
],
}

13
tools/refresh-keyboard.py Normal file
View file

@ -0,0 +1,13 @@
#!/usr/bin/env python3
"""Refresh the dictionary loader in Gaia's packaged keyboard settings bundle."""
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
source=(ROOT/'gaia/apps/keyboard/js/settings/layout_dictionary_downloader.js').read_text()
path=ROOT/'assets/webapps/keyboard.gaiamobile.org/gaia_build_defer_settings.js'
bundle=path.read_text()
start=bundle.index('(function(exports){var LayoutDictionaryDownloader=')
end=bundle.index('exports.LayoutDictionaryDownloader=LayoutDictionaryDownloader;}(window));',start)
end+=len('exports.LayoutDictionaryDownloader=LayoutDictionaryDownloader;}(window));')
out=ROOT/'overrides/keyboard.gaiamobile.org/gaia_build_defer_settings.js'
out.write_text(bundle[:start]+source+'\n'+bundle[end:])
print(out.relative_to(ROOT))

View file

@ -12,6 +12,7 @@ modules = [
('panels/wifi/wifi_network_list', 'js/panels/wifi/panel.js'),
('modules/page_transitions', 'js/main.js'),
('modules/settings_service', 'js/main.js'),
('modules/panel_cache', 'js/main.js'),
('modules/dialog_service', 'js/modules/dialog_service.js'),
('modules/dialog_manager', 'js/modules/dialog_service.js'),
('modules/date_time', 'js/modules/date_time.js'),

View file

@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Regression checks for storage navigation and unavailable-feature contrast."""
import importlib.util,json,time
from pathlib import Path
from marionette_client import Client
spec=importlib.util.spec_from_file_location('probe',Path(__file__).with_name('probe-desktop.py'))
p=importlib.util.module_from_spec(spec);spec.loader.exec_module(p)
with Client(2857) as c:
p.register(c)
def ev(code):
for _ in range(80):
reply=p.evaluate(c,'settings',code)
if not reply.get('missing'):break
time.sleep(.1)
if 'value' not in reply:raise RuntimeError(reply)
return reply['value']
def wait(code):
for _ in range(80):
if ev(code):return
time.sleep(.1)
raise TimeoutError(code)
p.evaluate(c,'system','if(Service.query("locked"))await Service.request("unlock",{forcibly:true});await VulpesCompat.call("apps.launch",{manifestURL:"http://settings.localhost:8765/manifest.webapp"});return true;')
ev('location.reload();return true;');time.sleep(.6)
wait('return document.querySelector("section.current")?.id==="root" && require.defined("modules/settings_service");')
for panel in ['mediaStorage','root','applicationStorage','root','usbStorage','root']:
ev('await new Promise(resolve=>require(["modules/settings_service"],s=>s.navigate('+json.dumps(panel)+',{},resolve)));return true;')
wait('return document.querySelector("section.current")?.id==='+json.dumps(panel)+';')
errors=ev('return window.__vulpesDiagnostics;')
assert not errors,errors
# A failed dependency must not leave the navigation lock set.
ev('const cache=require("modules/panel_cache"),get=cache.get;cache.get=function(id,cb){cache.get=get;cb(null,new Error("test dependency failure"));};await new Promise(resolve=>require("modules/settings_service").navigate("mediaStorage",{},resolve));return true;')
time.sleep(.3)
ev('await new Promise(resolve=>require("modules/settings_service").navigate("applicationStorage",{},resolve));return true;')
wait('return document.querySelector("section.current")?.id==="applicationStorage";')
ratios={}
for theme in ['light','dark']:
ev('document.documentElement.dataset.vulpesTheme='+json.dumps(theme)+';VulpesGaiaUI.notice("This feature is not available on this platform yet.");return true;')
time.sleep(.2)
colors=ev('const p=document.querySelector("#vulpes-gaia-notice p");return [getComputedStyle(p).color,getComputedStyle(p).backgroundColor];')
def luminance(css):
rgb=[float(v)/255 for v in css.removeprefix('rgb(').removesuffix(')').split(',')]
linear=[v/12.92 if v<=.04045 else ((v+.055)/1.055)**2.4 for v in rgb]
return sum(a*b for a,b in zip(linear,[.2126,.7152,.0722]))
a,b=sorted(map(luminance,colors));ratio=(b+.05)/(a+.05);assert ratio>=4.5,(theme,colors,ratio)
ratios[theme]=round(ratio,2)
ev('document.querySelector("#vulpes-gaia-notice").requestSubmit();return true;')
ev('require("modules/settings_service").navigate("root");return true;')
print(json.dumps({'storagePanels':3,'returnNavigation':True,'failureRecovery':True,'contrast':ratios}))