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.
This commit is contained in:
OverSu 2026-10-14 13:13:46 +02:00
commit 200d1fb235
43 changed files with 2271 additions and 65 deletions

View file

@ -4,6 +4,31 @@
Updates by date and platform. Within each group: Add, Change, Fix, Note, Test. Entries describe the state at their date; a fix in development requires an updated installed version. Updates by date and platform. Within each group: Add, Change, Fix, Note, Test. Entries describe the state at their date; a fix in development requires an updated installed version.
<a id="2.7-preview.21"></a>
## 14/10/2026 : 2.7-preview.21
![APK published](https://img.shields.io/badge/APK%20published-2F855A?style=flat-square)
### ![All Platforms](https://img.shields.io/badge/All%20Platforms-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Appearance](https://img.shields.io/badge/Appearance-475569?style=flat-square) : Unavailable-feature messages are easier to read in light and dark themes.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Call history](https://img.shields.io/badge/Call%20history-475569?style=flat-square) : Contact names now appear when the call history first loads.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Keyboard](https://img.shields.io/badge/Keyboard-475569?style=flat-square) : Additional dictionaries available offline; improved Gaia keyboard activation and recovery. Verification on affected devices is still needed.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Settings](https://img.shields.io/badge/Settings-475569?style=flat-square) : Storage panels restored. A loading error no longer locks the other menus.
### ![Android / APK](https://img.shields.io/badge/Android%20%2F%20APK-2563EB?style=flat-square)
- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Android data](https://img.shields.io/badge/Android%20data-475569?style=flat-square) : Read Android contacts, SMS and call history in Gaia after granting access. Existing local Vulpes data is preserved.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Marketplace](https://img.shields.io/badge/Marketplace-475569?style=flat-square) : Return to Vulpes without a persistent white overlay; a closed catalogue can be reopened.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Keyboard](https://img.shields.io/badge/Keyboard-475569?style=flat-square) : The reported blank keyboard still needs reproduction on the affected devices.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Android data](https://img.shields.io/badge/Android%20data-475569?style=flat-square) : First, read-only integration, excluding MMS. Sending and editing Android records use Android apps; new Gaia contacts stay local. SMS and call log access depend on ROM and installer permissions. Tested on Android 9; other versions need verification.
### ![Sargo / Pixel 3a](https://img.shields.io/badge/Sargo%20%2F%20Pixel%203a-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Shared Gaia fixes are included in full installation images and boot updates.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : New installation image built; Pixel boot and flashing must be tested before publication.
<a id="2.7-preview.20"></a> <a id="2.7-preview.20"></a>
## 13/10/2026 : 2.7-preview.20 ## 13/10/2026 : 2.7-preview.20

View file

@ -4,6 +4,31 @@
Les nouveautés, par date et par plateforme. Dans chaque groupe : Add, Change, Fix, Note, Test. Les entrées décrivent la situation à leur date ; une correction en développement nécessite une nouvelle version installée. Les nouveautés, par date et par plateforme. Dans chaque groupe : Add, Change, Fix, Note, Test. Les entrées décrivent la situation à leur date ; une correction en développement nécessite une nouvelle version installée.
<a id="2.7-preview.21"></a>
## 14/10/2026 : 2.7-preview.21
![APK publié](https://img.shields.io/badge/APK%20publi%C3%A9-2F855A?style=flat-square)
### ![Toutes les plateformes](https://img.shields.io/badge/Toutes%20les%20plateformes-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Affichage](https://img.shields.io/badge/Affichage-475569?style=flat-square) : Messages des fonctions indisponibles plus lisibles en thèmes clair et sombre.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Clavier](https://img.shields.io/badge/Clavier-475569?style=flat-square) : Dictionnaires supplémentaires disponibles hors ligne ; activation et reprise du clavier Gaia fiabilisées. La vérification sur les appareils concernés reste nécessaire.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Journal d’appels](https://img.shields.io/badge/Journal%20d%E2%80%99appels-475569?style=flat-square) : Affichage du nom des contacts rétabli au premier chargement du journal.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Paramètres](https://img.shields.io/badge/Param%C3%A8tres-475569?style=flat-square) : Panneaux Stockage rétablis. Une erreur de chargement ne bloque plus les autres menus.
### ![Android / APK](https://img.shields.io/badge/Android%20%2F%20APK-2563EB?style=flat-square)
- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Données Android](https://img.shields.io/badge/Donn%C3%A9es%20Android-475569?style=flat-square) : Lecture des contacts, SMS et journaux Android dans Gaia, après autorisation. Les données locales de Vulpes sont conservées.
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Marketplace](https://img.shields.io/badge/Marketplace-475569?style=flat-square) : Retour vers Vulpes sans écran blanc persistant ; un catalogue fermé peut être rouvert.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Clavier](https://img.shields.io/badge/Clavier-475569?style=flat-square) : Le rectangle blanc signalé reste à reproduire sur les appareils concernés.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Données Android](https://img.shields.io/badge/Donn%C3%A9es%20Android-475569?style=flat-square) : Première étape en lecture seule, hors MMS. Envoi et édition des données Android dans les applications Android ; les nouveaux contacts Gaia restent locaux. Accès SMS et journal selon les autorisations de la ROM et de l’installateur. Essais sur Android 9 ; autres versions à confirmer.
### ![Sargo / Pixel 3a](https://img.shields.io/badge/Sargo%20%2F%20Pixel%203a-2563EB?style=flat-square)
- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Les corrections communes de Gaia sont incluses dans les images complètes et les mises à jour de démarrage.
- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Nouvelle image d’installation construite ; démarrage et flash sur Pixel à vérifier avant publication.
<a id="2.7-preview.20"></a> <a id="2.7-preview.20"></a>
## 13/10/2026 : 2.7-preview.20 ## 13/10/2026 : 2.7-preview.20

View file

@ -1,5 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <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.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" /> <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; CONTACT = 412;
static final long MAX_MEDIA = 32L * 1024 * 1024; static final long MAX_MEDIA = 32L * 1024 * 1024;
final VulpesApplication app; final VulpesApplication app;
final AndroidPersonalData personal;
final ExecutorService io = Executors.newSingleThreadExecutor(); final ExecutorService io = Executors.newSingleThreadExecutor();
final Handler main = new Handler(Looper.getMainLooper()); final Handler main = new Handler(Looper.getMainLooper());
GeckoResult<Object> pending; GeckoResult<Object> pending;
@ -31,6 +32,7 @@ final class AndroidServices {
AndroidServices(VulpesApplication app) { AndroidServices(VulpesApplication app) {
this.app = app; this.app = app;
personal = new AndroidPersonalData(app);
} }
GeckoResult<Object> request(JSONObject message) { GeckoResult<Object> request(JSONObject message) {
@ -40,6 +42,30 @@ final class AndroidServices {
JSONObject p = message.optJSONObject("params"); JSONObject p = message.optJSONObject("params");
if (p == null) p = new JSONObject(); if (p == null) p = new JSONObject();
switch (op) { 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": case "android.notification":
JSONObject request = p; JSONObject request = p;
if (p.optString("operation").equals("permission")) { 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))); webPanel.addView(nav, new LinearLayout.LayoutParams(-1, dp(52)));
Button back = new Button(this); Button back = new Button(this);
back.setText("‹"); back.setText("‹");
back.setContentDescription("Retour au bureau"); back.setContentDescription("Retour");
back.setOnClickListener(v -> closeBrowser()); back.setOnClickListener(v -> back());
nav.addView(back, new LinearLayout.LayoutParams(dp(55), -1)); nav.addView(back, new LinearLayout.LayoutParams(dp(55), -1));
address = new EditText(this); address = new EditText(this);
address.setSingleLine(true); address.setSingleLine(true);
@ -99,6 +99,9 @@ public final class MainActivity extends Activity {
return true; return true;
}); });
webView = new GeckoView(this); 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)); webPanel.addView(webView, new LinearLayout.LayoutParams(-1, 0, 1));
home = new Button(this); home = new Button(this);
home.setText("◯"); home.setText("◯");
@ -297,10 +300,12 @@ public final class MainActivity extends Activity {
} }
void closeBrowser() { void closeBrowser() {
boolean wasVisible = webPanel.getVisibility() == View.VISIBLE;
webPanel.setVisibility(View.GONE); webPanel.setVisibility(View.GONE);
gaiaView.setVisibility(View.VISIBLE); gaiaView.setVisibility(View.VISIBLE);
setHomeSurface(app.homeVisible); setHomeSurface(app.homeVisible);
syncActive(); syncActive();
if (wasVisible) app.browserClosed();
} }
private void back() { private void back() {
@ -470,7 +475,7 @@ public final class MainActivity extends Activity {
else callback.accept(granted); 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; boolean granted = true;
for (String name : names) for (String name : names)
granted &= checkSelfPermission(name) == PackageManager.PERMISSION_GRANTED; 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) { void home(boolean held) {
if (port == null) return; if (port == null) return;
try { try {
@ -160,6 +169,7 @@ public final class VulpesApplication extends Application {
} }
void resumed() { void resumed() {
if (services != null) services.personal.observe();
sendScreenLock(); sendScreenLock();
if (port == null) return; if (port == null) return;
try { try {

View file

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

View file

@ -37,10 +37,8 @@
} }
if (message.event) { if (message.event) {
if(message.event.type==='input-mode') { if(message.event.type==='input-mode') {
gaiaKeyboard=message.event.data.enabled===true; inputConfigurationRevision++;
if(!gaiaKeyboard) restoreMode(); configureInput(message.event.data.enabled===true);
win.VulpesNativeInput=gaiaKeyboard;
win.dispatchEvent(new window.Event('vulpes-input-configured'));
} }
win.dispatchEvent( win.dispatchEvent(
new window.CustomEvent('vulpes-service-event', { detail: cloneInto(message.event, win) }), new window.CustomEvent('vulpes-service-event', { detail: cloneInto(message.event, win) }),
@ -53,6 +51,8 @@
callback.resolve(cloneInto(message.reply, win)); callback.resolve(cloneInto(message.reply, win));
} }
function onDisconnect() { function onDisconnect() {
inputConfigurationRevision++;
configureInput(false);
for (const callback of pending.values()) for (const callback of pending.values())
callback.reject(new window.Error('VULPES_HOST_CLOSED')); callback.reject(new window.Error('VULPES_HOST_CLOSED'));
pending.clear(); pending.clear();
@ -60,14 +60,15 @@
reconnect = setTimeout(() => { reconnect = setTimeout(() => {
try { try {
connect(); connect();
refreshInputConfiguration();
} catch (_) { } catch (_) {
onDisconnect(); onDisconnect();
} }
}, 1000); }, 1000);
} }
connect();
const editor = new InputEditor(window); const editor = new InputEditor(window);
let gaiaKeyboard = false; let gaiaKeyboard = false;
let inputConfigurationRevision = 0;
const originalModes = new WeakMap(); const originalModes = new WeakMap();
function restoreMode() { function restoreMode() {
const field = editor.inputElement; const field = editor.inputElement;
@ -77,21 +78,27 @@
else field.setAttribute('inputmode', value); else field.setAttribute('inputmode', value);
originalModes.delete(field); originalModes.delete(field);
} }
request({version:1,id:0,method:'input.config',params:{}}).then(reply => { function configureInput(enabled) {
gaiaKeyboard = reply.result === true; gaiaKeyboard = enabled;
if(!enabled) restoreMode();
win.VulpesNativeInput = gaiaKeyboard; win.VulpesNativeInput = gaiaKeyboard;
win.dispatchEvent(new window.Event('vulpes-input-configured')); win.dispatchEvent(new window.Event('vulpes-input-configured'));
}).catch(error => console.error('Gaia input configuration:', error)); if(enabled && !document.hidden && document.hasFocus()) reportFocus(document.activeElement);
for (const type of ['focusin', 'pointerdown', 'click']) addEventListener(type, event => { }
if (!gaiaKeyboard || editor.editing || location.hostname === 'keyboard.localhost') return; function refreshInputConfiguration() {
let element = event.target; 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; if (element?.isContentEditable) element=element.closest('[contenteditable=true]') || element;
const editable = element?.isContentEditable || const editable = element?.isContentEditable ||
(['input','textarea'].includes(element?.localName) && (['input','textarea'].includes(element?.localName) &&
!['button','submit','range','checkbox','radio','date','time','datetime-local','file'].includes(element.type)); !['button','submit','range','checkbox','radio','date','time','datetime-local','file'].includes(element.type));
if (!editable || element.disabled || element.readOnly) { if (!editable || element.disabled || element.readOnly) {
if (type === 'click' && !element?.closest?.('[data-vulpes-keep-input]')) port.postMessage({inputFocus:null}); return false;
return;
} }
if (element !== editor.inputElement) restoreMode(); if (element !== editor.inputElement) restoreMode();
editor.focus(element); editor.focus(element);
@ -101,9 +108,18 @@
const state=editor.state(); const state=editor.state();
state.inputMode=originalModes.get(element) || (element.classList.contains('recipient')?'tel':''); state.inputMode=originalModes.get(element) || (element.classList.contains('recipient')?'tel':'');
port.postMessage({inputFocus:state}); 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); }, true);
addEventListener('visibilitychange', () => { addEventListener('visibilitychange', () => {
if (document.hidden && gaiaKeyboard) { restoreMode(); port.postMessage({inputFocus:null}); } if (document.hidden && gaiaKeyboard) { restoreMode(); port.postMessage({inputFocus:null}); }
else if (gaiaKeyboard && document.hasFocus()) reportFocus(document.activeElement);
}); });
addEventListener('unload', () => clearTimeout(reconnect)); addEventListener('unload', () => clearTimeout(reconnect));
exportFunction(request, win.navigator, { defineAs: 'vulpesRequest' }); 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 import json,sys,time
from rdp import RDP from rdp import RDP
def evaluate(host, script): def evaluate(host, script, connection=None):
r=RDP() hostname, _, path = host.partition('/')
r=connection or RDP()
try: try:
for process in r.call('root','listProcesses')['processes']: for process in r.call('root','listProcesses')['processes']:
if process['isParent']: continue if process['isParent']: continue
target=r.call(process['actor'],'getTarget')['process'] target=r.call(process['actor'],'getTarget')['process']
if 'webIsolated' not in target.get('remoteType',''): continue 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) response=r.call(target['consoleActor'],'evaluateJSAsync',text=text)
if 'resultID' not in response: continue if 'resultID' not in response: continue
while True: 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') if value.get('result') is not None and value.get('result') != {'type':'null'}: return value.get('result')
break break
raise RuntimeError('App not open: '+host) 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 start=time.monotonic()+timeout
while True: while True:
try: 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 break
except (RuntimeError, EOFError, OSError) as e: except RuntimeError as e:
if time.monotonic()>=start:raise 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) time.sleep(.3)
until=time.monotonic()+timeout until=time.monotonic()+timeout
while time.monotonic()<until: 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) if isinstance(result,str) and result != 'pending':return json.loads(result)
time.sleep(.3) time.sleep(.3)
raise TimeoutError(host) 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__': if __name__=='__main__':
print(json.dumps(run(sys.argv[1],sys.argv[2]),ensure_ascii=False,indent=2)) 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: while True:
r = self.recv() r = self.recv()
self.events.append(r) 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"]): if r.get("from") == to and (r.get("type") not in ["consoleAPICall", "pageError"]):
return r 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;} 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;}} 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);} 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;} 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;} 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;} 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;} 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;}} 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);} 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;} 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;} 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;} 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 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;} 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('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 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;} function status(value){frame.style.visibility='hidden';message.hidden=false;document.getElementById('status').textContent=value;}
async function prepare(url){ async function prepare(url){

View file

@ -5,6 +5,25 @@
"en": "# Changelog — Vulpes OS\n\n[Français](CHANGELOG.md)\n\nUpdates by date and platform. Within each group: Add, Change, Fix, Note, Test. Entries describe the state at their date; a fix in development requires an updated installed version.\n" "en": "# Changelog — Vulpes OS\n\n[Français](CHANGELOG.md)\n\nUpdates by date and platform. Within each group: Add, Change, Fix, Note, Test. Entries describe the state at their date; a fix in development requires an updated installed version.\n"
}, },
"entries": [ "entries": [
{
"id": "2.7-preview.21",
"text": {
"fr": "## 14/10/2026 : 2.7-preview.21\n\n![APK publié](https://img.shields.io/badge/APK%20publi%C3%A9-2F855A?style=flat-square)\n\n### ![Toutes les plateformes](https://img.shields.io/badge/Toutes%20les%20plateformes-2563EB?style=flat-square)\n\n- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Affichage](https://img.shields.io/badge/Affichage-475569?style=flat-square) : Messages des fonctions indisponibles plus lisibles en thèmes clair et sombre.\n- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Clavier](https://img.shields.io/badge/Clavier-475569?style=flat-square) : Dictionnaires supplémentaires disponibles hors ligne ; activation et reprise du clavier Gaia fiabilisées. La vérification sur les appareils concernés reste nécessaire.\n- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Journal d’appels](https://img.shields.io/badge/Journal%20d%E2%80%99appels-475569?style=flat-square) : Affichage du nom des contacts rétabli au premier chargement du journal.\n- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Paramètres](https://img.shields.io/badge/Param%C3%A8tres-475569?style=flat-square) : Panneaux Stockage rétablis. Une erreur de chargement ne bloque plus les autres menus.\n\n### ![Android / APK](https://img.shields.io/badge/Android%20%2F%20APK-2563EB?style=flat-square)\n\n- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Données Android](https://img.shields.io/badge/Donn%C3%A9es%20Android-475569?style=flat-square) : Lecture des contacts, SMS et journaux Android dans Gaia, après autorisation. Les données locales de Vulpes sont conservées.\n- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Marketplace](https://img.shields.io/badge/Marketplace-475569?style=flat-square) : Retour vers Vulpes sans écran blanc persistant ; un catalogue fermé peut être rouvert.\n- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Clavier](https://img.shields.io/badge/Clavier-475569?style=flat-square) : Le rectangle blanc signalé reste à reproduire sur les appareils concernés.\n- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Données Android](https://img.shields.io/badge/Donn%C3%A9es%20Android-475569?style=flat-square) : Première étape en lecture seule, hors MMS. Envoi et édition des données Android dans les applications Android ; les nouveaux contacts Gaia restent locaux. Accès SMS et journal selon les autorisations de la ROM et de l’installateur. Essais sur Android 9 ; autres versions à confirmer.\n\n### ![Sargo / Pixel 3a](https://img.shields.io/badge/Sargo%20%2F%20Pixel%203a-2563EB?style=flat-square)\n\n- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Les corrections communes de Gaia sont incluses dans les images complètes et les mises à jour de démarrage.\n- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Nouvelle image d’installation construite ; démarrage et flash sur Pixel à vérifier avant publication.\n",
"en": "## 14/10/2026 : 2.7-preview.21\n\n![APK published](https://img.shields.io/badge/APK%20published-2F855A?style=flat-square)\n\n### ![All Platforms](https://img.shields.io/badge/All%20Platforms-2563EB?style=flat-square)\n\n- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Appearance](https://img.shields.io/badge/Appearance-475569?style=flat-square) : Unavailable-feature messages are easier to read in light and dark themes.\n- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Call history](https://img.shields.io/badge/Call%20history-475569?style=flat-square) : Contact names now appear when the call history first loads.\n- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Keyboard](https://img.shields.io/badge/Keyboard-475569?style=flat-square) : Additional dictionaries available offline; improved Gaia keyboard activation and recovery. Verification on affected devices is still needed.\n- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Settings](https://img.shields.io/badge/Settings-475569?style=flat-square) : Storage panels restored. A loading error no longer locks the other menus.\n\n### ![Android / APK](https://img.shields.io/badge/Android%20%2F%20APK-2563EB?style=flat-square)\n\n- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Android data](https://img.shields.io/badge/Android%20data-475569?style=flat-square) : Read Android contacts, SMS and call history in Gaia after granting access. Existing local Vulpes data is preserved.\n- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Marketplace](https://img.shields.io/badge/Marketplace-475569?style=flat-square) : Return to Vulpes without a persistent white overlay; a closed catalogue can be reopened.\n- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Keyboard](https://img.shields.io/badge/Keyboard-475569?style=flat-square) : The reported blank keyboard still needs reproduction on the affected devices.\n- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Android data](https://img.shields.io/badge/Android%20data-475569?style=flat-square) : First, read-only integration, excluding MMS. Sending and editing Android records use Android apps; new Gaia contacts stay local. SMS and call log access depend on ROM and installer permissions. Tested on Android 9; other versions need verification.\n\n### ![Sargo / Pixel 3a](https://img.shields.io/badge/Sargo%20%2F%20Pixel%203a-2563EB?style=flat-square)\n\n- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Shared Gaia fixes are included in full installation images and boot updates.\n- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : New installation image built; Pixel boot and flashing must be tested before publication.\n"
},
"metadata": {
"kind": "version",
"status": "published",
"platforms": [
"pc",
"apk",
"models"
],
"models": [
"sargo"
]
}
},
{ {
"id": "2.7-preview.20", "id": "2.7-preview.20",
"text": { "text": {
@ -907,6 +926,13 @@
"url": "https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.1", "url": "https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.1",
"summary": "# Vulpes OS — 2.7-preview.1\n\n🇫🇷 Ancienne Preview Android expérimentale. Une version plus récente est disponible dans les releases du projet.\n\n🇬🇧 Older experimental Android Preview. A newer version is available in the project releases.", "summary": "# Vulpes OS — 2.7-preview.1\n\n🇫🇷 Ancienne Preview Android expérimentale. Une version plus récente est disponible dans les releases du projet.\n\n🇬🇧 Older experimental Android Preview. A newer version is available in the project releases.",
"include_entry": true "include_entry": true
},
"v2.7-preview.21": {
"version": "2.7-preview.21",
"after": "2.7-preview.20",
"url": "https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.21",
"include_entry": true,
"summary": "# Vulpes OS — 2.7-preview.21\n\n🇫🇷 **Preview Android expérimentale.** Contacts, SMS et journal d’appels Android accessibles dans Gaia après autorisation, en lecture seule. Les données locales Vulpes sont conservées. Les MMS ne sont pas synchronisés ; appels, envoi et modification des données Android passent par les applications Android.\n\nInstaller l’APK par-dessus une Preview précédente signée avec la même clé. Android 8 minimum déclaré ; ARM64, ARMv7 et x86_64. Intégration vérifiée sur émulateur Android 9 avec des données de test ; les Motorola signalés et les versions Android récentes restent à qualifier. Le problème de clavier blanc n’est pas considéré comme résolu sur tous les appareils.\n\nBuild de développement signé, débogage via ADB autorisé. Gaia 2.7.0 · GeckoView Android 156.0. Vérification du téléchargement : `sha256sum -c SHA256SUMS.txt`. **Aucune nouvelle image Pixel n’est publiée ici : son flash et ses tests restent à effectuer.**\n\n🇬🇧 **Experimental Android Preview.** Android contacts, SMS and call history can be read in Gaia after granting permission. Local Vulpes data is preserved. MMS are not synchronized; calls, sending and editing Android records use Android apps.\n\nInstall over a previous Preview signed with the same key. Declared minimum Android 8; ARM64, ARMv7 and x86_64. Integration checked on an Android 9 emulator with test data; reported Motorola devices and recent Android versions still need qualification. The blank keyboard issue is not considered fixed on every device.\n\nSigned development build, debugging through authorized ADB. Gaia 2.7.0 · Android GeckoView 156.0. Verify the download with `sha256sum -c SHA256SUMS.txt`. **No new Pixel image is published here: flashing and hardware tests are still pending.**\n"
} }
} }
} }

View file

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

View file

@ -9,10 +9,9 @@ var LayoutDictionaryDownloader = function(imEngineId, dictFilename) {
this._xhr = null; this._xhr = null;
}; };
// Remote URLs to download the data. We require the remote server to have // Dictionaries ship with Vulpes so adding a layout also works offline.
// CORS turned on since we are not asking for SystemXHR permission.
LayoutDictionaryDownloader.prototype.REMOTE_URL = 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 // If the data schema changes in an incompatible way, we must bump the
// version-to-download here. // 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 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;} 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('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 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;} function status(value){frame.style.visibility='hidden';message.hidden=false;document.getElementById('status').textContent=value;}
async function prepare(url){ async function prepare(url){

View file

@ -86,7 +86,16 @@ define(function(require) {
var path = pathElement ? pathElement.dataset.path : null; var path = pathElement ? pathElement.dataset.path : null;
var panelFuncLoaded = function(panelFunc) { 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; _panelCache[panelId] = panel;
if (callback) { if (callback) {
callback(panel); callback(panel);
@ -97,6 +106,8 @@ define(function(require) {
require([path], function(panelFunc) { require([path], function(panelFunc) {
// Create a new panel object for static panels. // Create a new panel object for static panels.
panelFuncLoaded(panelFunc ? panelFunc : SettingsPanel); panelFuncLoaded(panelFunc ? panelFunc : SettingsPanel);
}, function(error) {
if (callback) callback(null, error);
}); });
} else { } else {
panelFuncLoaded(SettingsPanel); panelFuncLoaded(SettingsPanel);

View file

@ -187,7 +187,12 @@ define(function(require) {
} }
_loadPanel(panelId, function() { _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 newPanelElement = document.getElementById(panelId);
var currentPanelId = var currentPanelId =
_currentNavigation && _currentNavigation.panelId; _currentNavigation && _currentNavigation.panelId;

View file

@ -408,14 +408,13 @@
var appStarter = AppStarter(); 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(); appStarter.start();
} else { } else {
document.addEventListener('readystatechange', function readyStateChange() { document.addEventListener('DOMContentLoaded', function() {
if (document.readyState == 'interactive') { appStarter.start();
document.removeEventListener('readystatechange', readyStateChange); }, { once: true });
appStarter.start();
}
});
} }
})(); })();

View file

@ -55,8 +55,15 @@
ime.dispatchEvent(new Event('inputcontextchange')); ime.dispatchEvent(new Event('inputcontextchange'));
}; };
Object.defineProperty(navigator,'mozInputMethod',{value:ime}); Object.defineProperty(navigator,'mozInputMethod',{value:ime});
addEventListener('vulpes-service-event',({detail})=>{if(detail.type==='input-focus')setState(detail.data);}); let focusReceived=false;
call('input.request',{operation:'state'}).then(setState).catch(console.error); 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); window.resizeTo=(_width,height)=>call('input.request',{operation:'height',height}).catch(console.error);
} }
if(app==='system') { if(app==='system') {

View file

@ -103,3 +103,25 @@
background-color: var(--vulpes-surface) !important; background-color: var(--vulpes-surface) !important;
color: var(--vulpes-text) !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;} 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.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;} 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;} _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) {
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;} 'use strict';
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]);} 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;} 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;} 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;}}} 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() { _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 newPanelElement = document.getElementById(panelId);
var currentPanelId = var currentPanelId =
_currentNavigation && _currentNavigation.panelId; _currentNavigation && _currentNavigation.panelId;

View file

@ -1,15 +1,15 @@
{ {
"gaia": "2.7.0", "gaia": "2.7.0",
"channel": "preview", "channel": "preview",
"revision": 20, "revision": 21,
"androidVersionCode": 20, "androidVersionCode": 21,
"geckoview": "156.0.20260921121718", "geckoview": "156.0.20260921121718",
"engineAlignment": { "engineAlignment": {
"family": "156", "family": "156",
"patchParity": false, "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." "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": { "adapters": {
"desktop": "0.1.0-dev", "desktop": "0.1.0-dev",
"android": "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 vm from 'node:vm';
import {readFileSync} from 'node:fs'; import {readFileSync} from 'node:fs';
const source=readFileSync(new URL('../android/extension/content.js',import.meta.url),'utf8'); const source=readFileSync(new URL('../android/extension/content.js',import.meta.url),'utf8');
async function setup(enabled=true) { async function setup(enabled=true,{defer=false,activeElement}={}) {
const sent=[],events=new Map();let receive; const sent=[],events=new Map(),timers=[];let receive,disconnect;
const page={navigator:{},dispatchEvent(){}}; 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 { class Editor {
constructor(){this.editing=false;} constructor(){this.editing=false;}
focus(e){this.inputElement=e;} focus(e){this.inputElement=e;}
@ -14,14 +14,14 @@ async function setup(enabled=true) {
edit(){return this.state();} edit(){return this.state();}
} }
const context={InputEditor:Editor,browser:{runtime:{connect:()=>port}},console, 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;}}}, 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;}, 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); 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)); await new Promise(resolve=>setImmediate(resolve));
return {sent,events,page,receive,context}; return {sent,events,page,receive,context,timers,disconnect};
} }
function field(mode='numeric') { function field(mode='numeric') {
const attrs=new Map([['inputmode',mode]]); 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(e.getAttribute('inputmode'),'numeric');
assert.equal(s.sent.at(-1).inputFocus,null); 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

@ -74,7 +74,7 @@ class ChangelogTests(unittest.TestCase):
def test_date_first_heading_and_platform_badges(self): def test_date_first_heading_and_platform_badges(self):
for language in export.LANGUAGES: for language in export.LANGUAGES:
entry = self.languages[language][0] 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['id'], '2.7-preview.20')
self.assertEqual(entry['date'], '2026-10-13') self.assertEqual(entry['date'], '2026-10-13')
common = next(g for g in entry['groups'] if g['common']) common = next(g for g in entry['groups'] if g['common'])
@ -98,7 +98,7 @@ class ChangelogTests(unittest.TestCase):
def test_release_summary_follows_the_entry_without_copying_text(self): def test_release_summary_follows_the_entry_without_copying_text(self):
tag = 'v2.7-preview.20' tag = 'v2.7-preview.20'
self.source['entries'][0]['text']['fr'] += '\nA new correction.\n' 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) docs = export.release_documents(self.source, tag)
self.assertIn('A new correction.', docs['release-body.md']) self.assertIn('A new correction.', docs['release-body.md'])

View file

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

View file

@ -11,6 +11,11 @@ SOURCE_OVERRIDES = {
"system": ["js/launcher.js", "js/gaia_scheduler.js", "js/keyboard_manager.js", "system": ["js/launcher.js", "js/gaia_scheduler.js", "js/keyboard_manager.js",
"js/core.js", "js/rocketbar.js"], "js/core.js", "js/rocketbar.js"],
"communications": ["dialer/js/telephony_helper.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'), ('panels/wifi/wifi_network_list', 'js/panels/wifi/panel.js'),
('modules/page_transitions', 'js/main.js'), ('modules/page_transitions', 'js/main.js'),
('modules/settings_service', '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_service', 'js/modules/dialog_service.js'),
('modules/dialog_manager', 'js/modules/dialog_service.js'), ('modules/dialog_manager', 'js/modules/dialog_service.js'),
('modules/date_time', 'js/modules/date_time.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}))