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:
parent
1c20a0b6d5
commit
200d1fb235
43 changed files with 2271 additions and 65 deletions
|
|
@ -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.
|
||||
|
||||
<a id="2.7-preview.21"></a>
|
||||
|
||||
## 14/10/2026 : 2.7-preview.21
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Unavailable-feature messages are easier to read in light and dark themes.
|
||||
-   : Contact names now appear when the call history first loads.
|
||||
-   : Additional dictionaries available offline; improved Gaia keyboard activation and recovery. Verification on affected devices is still needed.
|
||||
-   : Storage panels restored. A loading error no longer locks the other menus.
|
||||
|
||||
### 
|
||||
|
||||
-   : Read Android contacts, SMS and call history in Gaia after granting access. Existing local Vulpes data is preserved.
|
||||
-   : Return to Vulpes without a persistent white overlay; a closed catalogue can be reopened.
|
||||
-   : The reported blank keyboard still needs reproduction on the affected devices.
|
||||
-   : 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.
|
||||
|
||||
### 
|
||||
|
||||
-   : Shared Gaia fixes are included in full installation images and boot updates.
|
||||
-   : New installation image built; Pixel boot and flashing must be tested before publication.
|
||||
|
||||
<a id="2.7-preview.20"></a>
|
||||
|
||||
## 13/10/2026 : 2.7-preview.20
|
||||
|
|
|
|||
25
CHANGELOG.md
25
CHANGELOG.md
|
|
@ -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.
|
||||
|
||||
<a id="2.7-preview.21"></a>
|
||||
|
||||
## 14/10/2026 : 2.7-preview.21
|
||||
|
||||

|
||||
|
||||
### 
|
||||
|
||||
-   : Messages des fonctions indisponibles plus lisibles en thèmes clair et sombre.
|
||||
-   : 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.
|
||||
-   : Affichage du nom des contacts rétabli au premier chargement du journal.
|
||||
-   : Panneaux Stockage rétablis. Une erreur de chargement ne bloque plus les autres menus.
|
||||
|
||||
### 
|
||||
|
||||
-   : Lecture des contacts, SMS et journaux Android dans Gaia, après autorisation. Les données locales de Vulpes sont conservées.
|
||||
-   : Retour vers Vulpes sans écran blanc persistant ; un catalogue fermé peut être rouvert.
|
||||
-   : Le rectangle blanc signalé reste à reproduire sur les appareils concernés.
|
||||
-   : 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.
|
||||
|
||||
### 
|
||||
|
||||
-   : Les corrections communes de Gaia sont incluses dans les images complètes et les mises à jour de démarrage.
|
||||
-   : Nouvelle image d’installation construite ; démarrage et flash sur Pixel à vérifier avant publication.
|
||||
|
||||
<a id="2.7-preview.20"></a>
|
||||
|
||||
## 13/10/2026 : 2.7-preview.20
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.READ_SMS" />
|
||||
<uses-permission android:name="android.permission.READ_CALL_LOG" />
|
||||
<uses-feature android:name="android.hardware.telephony" android:required="false" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ final class AndroidServices {
|
|||
CONTACT = 412;
|
||||
static final long MAX_MEDIA = 32L * 1024 * 1024;
|
||||
final VulpesApplication app;
|
||||
final AndroidPersonalData personal;
|
||||
final ExecutorService io = Executors.newSingleThreadExecutor();
|
||||
final Handler main = new Handler(Looper.getMainLooper());
|
||||
GeckoResult<Object> pending;
|
||||
|
|
@ -31,6 +32,7 @@ final class AndroidServices {
|
|||
|
||||
AndroidServices(VulpesApplication app) {
|
||||
this.app = app;
|
||||
personal = new AndroidPersonalData(app);
|
||||
}
|
||||
|
||||
GeckoResult<Object> request(JSONObject message) {
|
||||
|
|
@ -40,6 +42,30 @@ final class AndroidServices {
|
|||
JSONObject p = message.optJSONObject("params");
|
||||
if (p == null) p = new JSONObject();
|
||||
switch (op) {
|
||||
case "android.personal":
|
||||
JSONObject personalParams = p;
|
||||
String operation = p.optString("operation");
|
||||
if (operation.equals("permission")) {
|
||||
String kind = p.getString("kind");
|
||||
activity().permission(new String[]{AndroidPersonalData.permission(kind)}, granted -> {
|
||||
try { personal.enable(kind, granted); result.complete(personal.status()); } catch (Exception e) { fail(result, e); }
|
||||
});
|
||||
} else if (operation.equals("status")) result.complete(personal.status());
|
||||
else if (operation.equals("disconnect")) {
|
||||
personal.enable(p.getString("kind"), false);
|
||||
result.complete(personal.status());
|
||||
}
|
||||
else if (operation.equals("open")) {
|
||||
activity().startActivity(personal.open(p));
|
||||
result.complete(null);
|
||||
} else if (operation.equals("page")) io.execute(() -> {
|
||||
try {
|
||||
JSONObject data = personal.page(personalParams);
|
||||
main.post(() -> result.complete(data));
|
||||
} catch (Exception e) { fail(result, e); }
|
||||
});
|
||||
else throw new IllegalArgumentException("METHOD_NOT_SUPPORTED");
|
||||
break;
|
||||
case "android.notification":
|
||||
JSONObject request = p;
|
||||
if (p.optString("operation").equals("permission")) {
|
||||
|
|
|
|||
|
|
@ -82,8 +82,8 @@ public final class MainActivity extends Activity {
|
|||
webPanel.addView(nav, new LinearLayout.LayoutParams(-1, dp(52)));
|
||||
Button back = new Button(this);
|
||||
back.setText("‹");
|
||||
back.setContentDescription("Retour au bureau");
|
||||
back.setOnClickListener(v -> closeBrowser());
|
||||
back.setContentDescription("Retour");
|
||||
back.setOnClickListener(v -> back());
|
||||
nav.addView(back, new LinearLayout.LayoutParams(dp(55), -1));
|
||||
address = new EditText(this);
|
||||
address.setSingleLine(true);
|
||||
|
|
@ -99,6 +99,9 @@ public final class MainActivity extends Activity {
|
|||
return true;
|
||||
});
|
||||
webView = new GeckoView(this);
|
||||
// The web panel is layered over Gaia and hidden when returning to an app.
|
||||
// A TextureView follows that visibility without leaving a separate surface.
|
||||
webView.setViewBackend(GeckoView.BACKEND_TEXTURE_VIEW);
|
||||
webPanel.addView(webView, new LinearLayout.LayoutParams(-1, 0, 1));
|
||||
home = new Button(this);
|
||||
home.setText("◯");
|
||||
|
|
@ -297,10 +300,12 @@ public final class MainActivity extends Activity {
|
|||
}
|
||||
|
||||
void closeBrowser() {
|
||||
boolean wasVisible = webPanel.getVisibility() == View.VISIBLE;
|
||||
webPanel.setVisibility(View.GONE);
|
||||
gaiaView.setVisibility(View.VISIBLE);
|
||||
setHomeSurface(app.homeVisible);
|
||||
syncActive();
|
||||
if (wasVisible) app.browserClosed();
|
||||
}
|
||||
|
||||
private void back() {
|
||||
|
|
@ -470,7 +475,7 @@ public final class MainActivity extends Activity {
|
|||
else callback.accept(granted);
|
||||
}
|
||||
|
||||
private void permission(String[] names, java.util.function.Consumer<Boolean> callback) {
|
||||
void permission(String[] names, java.util.function.Consumer<Boolean> callback) {
|
||||
boolean granted = true;
|
||||
for (String name : names)
|
||||
granted &= checkSelfPermission(name) == PackageManager.PERMISSION_GRANTED;
|
||||
|
|
|
|||
|
|
@ -150,6 +150,15 @@ public final class VulpesApplication extends Application {
|
|||
);
|
||||
}
|
||||
|
||||
void browserClosed() {
|
||||
if (port == null) return;
|
||||
try {
|
||||
port.postMessage(new JSONObject().put("type", "browser-closed"));
|
||||
} catch (Exception e) {
|
||||
Log.w("Vulpes", "Browser close notification", e);
|
||||
}
|
||||
}
|
||||
|
||||
void home(boolean held) {
|
||||
if (port == null) return;
|
||||
try {
|
||||
|
|
@ -160,6 +169,7 @@ public final class VulpesApplication extends Application {
|
|||
}
|
||||
|
||||
void resumed() {
|
||||
if (services != null) services.personal.observe();
|
||||
sendScreenLock();
|
||||
if (port == null) return;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import {AndroidPersonalData, authorizePersonal} from './personal-data.mjs';
|
||||
import {Input} from './services/input.mjs';
|
||||
import { Packages, packageURL } from './services/packages.mjs';
|
||||
import { Activities, isGaiaActivity } from './services/activities.mjs';
|
||||
|
|
@ -15,7 +16,7 @@ const input = new Input({identity:peer=>peer.app,broadcast});
|
|||
let editSequence=0;
|
||||
const nativePackages=(operation,data={})=>browser.runtime.sendNativeMessage('vulpes',{type:'packages.'+operation,...data}).then(JSON.parse);
|
||||
async function refreshInstalled(){const installed=await nativePackages('list');apps.splice(0,apps.length,...apps.filter(a=>!a.installed),...installed);}
|
||||
let apps, values, settings, dataStores, contacts, messages, connections, alarms, storage;
|
||||
let apps, values, settings, dataStores, contacts, messages, connections, alarms, storage, personal;
|
||||
const identity = (peer) => peer.app;
|
||||
function notify(peer, type, data) {
|
||||
try {
|
||||
|
|
@ -121,6 +122,7 @@ const ready = (async () => {
|
|||
contacts = new Contacts(storage, (data) => broadcast('contactchange', data));
|
||||
await contacts.init();
|
||||
messages = new Messages(storage);
|
||||
personal = new AndroidPersonalData(params=>android("personal",params), contacts, messages, storage);
|
||||
dataStores = new DataStoreService(apps, storage, (data) => broadcast('datastorechange', data));
|
||||
connections = new Connections(identity, notify);
|
||||
alarms = new Alarms(
|
||||
|
|
@ -140,6 +142,7 @@ const ready = (async () => {
|
|||
})();
|
||||
// Java owns this port. Web pages have no access to native messaging.
|
||||
let native;
|
||||
let activeWebView;
|
||||
let systemReady = false;
|
||||
let pendingScreenLock = false;
|
||||
function deliverScreenLock() {
|
||||
|
|
@ -163,15 +166,26 @@ function connectNative() {
|
|||
native.onDisconnect.addListener(() => setTimeout(connectNative, 1000));
|
||||
native.onMessage.addListener(async (message) => {
|
||||
await ready;
|
||||
if (message.type === 'browser-closed' && activeWebView) {
|
||||
const {peer, contextId} = activeWebView;
|
||||
activeWebView = null;
|
||||
notify(peer, 'view', {contextId, type:'close', detail:{}});
|
||||
}
|
||||
if (['home', 'holdhome'].includes(message.type)) broadcast(message.type, {}, 'system');
|
||||
if (message.type === 'android-screen-off') {
|
||||
pendingScreenLock = true;
|
||||
deliverScreenLock();
|
||||
}
|
||||
if (message.type === 'android-data-changed') {
|
||||
broadcast('android-data-changed', {kind:message.kind}, message.kind === 'sms' ? 'sms' : 'communications');
|
||||
}
|
||||
if (message.type === 'android-resume') {
|
||||
deliverScreenLock();
|
||||
broadcast('screen-on', {}, 'system');
|
||||
await refreshAndroid().catch(console.warn);
|
||||
broadcast('android-data-changed', {kind:'contacts'}, 'communications');
|
||||
broadcast('android-data-changed', {kind:'calls'}, 'communications');
|
||||
broadcast('android-data-changed', {kind:'sms'}, 'sms');
|
||||
}
|
||||
if (message.type === 'notification-click') {
|
||||
pendingNotification = message.data;
|
||||
|
|
@ -215,6 +229,12 @@ async function handle(peer, request) {
|
|||
authorizePlatform(app, method.slice(9), params);
|
||||
method = 'android.' + method.slice(9);
|
||||
}
|
||||
if (method === 'android.personal') {
|
||||
authorizePersonal(app, params.kind, params.operation);
|
||||
if (params.operation === 'page') return personal.rows(params.kind);
|
||||
if (!['status','permission','disconnect','open'].includes(params.operation)) throw Error('METHOD_NOT_SUPPORTED');
|
||||
return android('personal', params);
|
||||
}
|
||||
if (method === 'android.capabilities') {
|
||||
const release = await (await fetch('release.json')).json();
|
||||
const device = app.id === 'settings' ? await android('snapshot') : {};
|
||||
|
|
@ -355,8 +375,8 @@ async function handle(peer, request) {
|
|||
}
|
||||
if (method === 'datastores.list') return dataStores.list(app, params.name);
|
||||
if (method === 'datastores.run') return dataStores.run(app, params);
|
||||
if (method === 'contacts.request') return contacts.request(app, params);
|
||||
if (method === 'messages.request') return messages.request(app, params);
|
||||
if (method === 'contacts.request') return personal.contactRequest(app, params);
|
||||
if (method === 'messages.request') return personal.messageRequest(app, params);
|
||||
if (method === 'alarms.request') return alarms.request(peer, app, params);
|
||||
if (method === 'iac.subscribe') {
|
||||
connections.subscribe(peer);
|
||||
|
|
@ -397,6 +417,7 @@ async function handle(peer, request) {
|
|||
throw Error('PERMISSION_DENIED');
|
||||
if (params.operation === 'open') {
|
||||
await openWeb(params.url);
|
||||
activeWebView = {peer, contextId:params.contextId};
|
||||
notify(peer, 'view', {
|
||||
contextId: params.contextId,
|
||||
type: 'loadend',
|
||||
|
|
@ -502,6 +523,7 @@ browser.runtime.onConnect.addListener((port) => {
|
|||
attached
|
||||
.then(() => {
|
||||
peers.delete(peer);
|
||||
if (activeWebView?.peer === peer) activeWebView = null;
|
||||
input.cleanup(peer);
|
||||
for(const edit of peer.edits.values()) {clearTimeout(edit.timer);edit.reject(Error('INPUT_CONTEXT_EXPIRED'));}
|
||||
peer.edits.clear();
|
||||
|
|
|
|||
|
|
@ -37,10 +37,8 @@
|
|||
}
|
||||
if (message.event) {
|
||||
if(message.event.type==='input-mode') {
|
||||
gaiaKeyboard=message.event.data.enabled===true;
|
||||
if(!gaiaKeyboard) restoreMode();
|
||||
win.VulpesNativeInput=gaiaKeyboard;
|
||||
win.dispatchEvent(new window.Event('vulpes-input-configured'));
|
||||
inputConfigurationRevision++;
|
||||
configureInput(message.event.data.enabled===true);
|
||||
}
|
||||
win.dispatchEvent(
|
||||
new window.CustomEvent('vulpes-service-event', { detail: cloneInto(message.event, win) }),
|
||||
|
|
@ -53,6 +51,8 @@
|
|||
callback.resolve(cloneInto(message.reply, win));
|
||||
}
|
||||
function onDisconnect() {
|
||||
inputConfigurationRevision++;
|
||||
configureInput(false);
|
||||
for (const callback of pending.values())
|
||||
callback.reject(new window.Error('VULPES_HOST_CLOSED'));
|
||||
pending.clear();
|
||||
|
|
@ -60,14 +60,15 @@
|
|||
reconnect = setTimeout(() => {
|
||||
try {
|
||||
connect();
|
||||
refreshInputConfiguration();
|
||||
} catch (_) {
|
||||
onDisconnect();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
connect();
|
||||
const editor = new InputEditor(window);
|
||||
let gaiaKeyboard = false;
|
||||
let inputConfigurationRevision = 0;
|
||||
const originalModes = new WeakMap();
|
||||
function restoreMode() {
|
||||
const field = editor.inputElement;
|
||||
|
|
@ -77,21 +78,27 @@
|
|||
else field.setAttribute('inputmode', value);
|
||||
originalModes.delete(field);
|
||||
}
|
||||
request({version:1,id:0,method:'input.config',params:{}}).then(reply => {
|
||||
gaiaKeyboard = reply.result === true;
|
||||
function configureInput(enabled) {
|
||||
gaiaKeyboard = enabled;
|
||||
if(!enabled) restoreMode();
|
||||
win.VulpesNativeInput = gaiaKeyboard;
|
||||
win.dispatchEvent(new window.Event('vulpes-input-configured'));
|
||||
if(enabled && !document.hidden && document.hasFocus()) reportFocus(document.activeElement);
|
||||
}
|
||||
function refreshInputConfiguration() {
|
||||
const revision = inputConfigurationRevision;
|
||||
request({version:1,id:0,method:'input.config',params:{}}).then(reply => {
|
||||
if(revision===inputConfigurationRevision) configureInput(reply.result===true);
|
||||
}).catch(error => console.error('Gaia input configuration:', error));
|
||||
for (const type of ['focusin', 'pointerdown', 'click']) addEventListener(type, event => {
|
||||
if (!gaiaKeyboard || editor.editing || location.hostname === 'keyboard.localhost') return;
|
||||
let element = event.target;
|
||||
}
|
||||
function reportFocus(element) {
|
||||
if (!gaiaKeyboard || editor.editing || location.hostname === 'keyboard.localhost') return false;
|
||||
if (element?.isContentEditable) element=element.closest('[contenteditable=true]') || element;
|
||||
const editable = element?.isContentEditable ||
|
||||
(['input','textarea'].includes(element?.localName) &&
|
||||
!['button','submit','range','checkbox','radio','date','time','datetime-local','file'].includes(element.type));
|
||||
if (!editable || element.disabled || element.readOnly) {
|
||||
if (type === 'click' && !element?.closest?.('[data-vulpes-keep-input]')) port.postMessage({inputFocus:null});
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (element !== editor.inputElement) restoreMode();
|
||||
editor.focus(element);
|
||||
|
|
@ -101,9 +108,18 @@
|
|||
const state=editor.state();
|
||||
state.inputMode=originalModes.get(element) || (element.classList.contains('recipient')?'tel':'');
|
||||
port.postMessage({inputFocus:state});
|
||||
return true;
|
||||
}
|
||||
connect();
|
||||
refreshInputConfiguration();
|
||||
for (const type of ['focusin', 'pointerdown', 'click']) addEventListener(type, event => {
|
||||
if (!gaiaKeyboard || editor.editing || location.hostname === 'keyboard.localhost') return;
|
||||
if (!reportFocus(event.target) && type === 'click' &&
|
||||
!event.target?.closest?.('[data-vulpes-keep-input]')) port.postMessage({inputFocus:null});
|
||||
}, true);
|
||||
addEventListener('visibilitychange', () => {
|
||||
if (document.hidden && gaiaKeyboard) { restoreMode(); port.postMessage({inputFocus:null}); }
|
||||
else if (gaiaKeyboard && document.hasFocus()) reportFocus(document.activeElement);
|
||||
});
|
||||
addEventListener('unload', () => clearTimeout(reconnect));
|
||||
exportFunction(request, win.navigator, { defineAs: 'vulpesRequest' });
|
||||
|
|
|
|||
88
android/extension/personal-data.mjs
Normal file
88
android/extension/personal-data.mjs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,14 +3,15 @@
|
|||
import json,sys,time
|
||||
from rdp import RDP
|
||||
|
||||
def evaluate(host, script):
|
||||
r=RDP()
|
||||
def evaluate(host, script, connection=None):
|
||||
hostname, _, path = host.partition('/')
|
||||
r=connection or RDP()
|
||||
try:
|
||||
for process in r.call('root','listProcesses')['processes']:
|
||||
if process['isParent']: continue
|
||||
target=r.call(process['actor'],'getTarget')['process']
|
||||
if 'webIsolated' not in target.get('remoteType',''): continue
|
||||
text='''(() => { const w=[...Services.ww.getWindowEnumerator()].find(w=>w.location.hostname===%s); if(!w)return null; return w.wrappedJSObject.eval(%s); })()''' % (json.dumps(host+'.localhost'),json.dumps(script))
|
||||
text='''(() => { const w=[...Services.ww.getWindowEnumerator()].find(w=>w.location.hostname===%s && (!%s || w.location.pathname.startsWith(%s))); if(!w)return null; return w.wrappedJSObject.eval(%s); })()''' % (json.dumps(hostname+'.localhost'),json.dumps(path),json.dumps('/'+path),json.dumps(script))
|
||||
response=r.call(target['consoleActor'],'evaluateJSAsync',text=text)
|
||||
if 'resultID' not in response: continue
|
||||
while True:
|
||||
|
|
@ -20,24 +21,32 @@ def evaluate(host, script):
|
|||
if value.get('result') is not None and value.get('result') != {'type':'null'}: return value.get('result')
|
||||
break
|
||||
raise RuntimeError('App not open: '+host)
|
||||
finally:r.s.close()
|
||||
finally:
|
||||
if connection is None:r.s.close()
|
||||
|
||||
def run(host,expression,timeout=30):
|
||||
def _run(host,expression,timeout,connection):
|
||||
start=time.monotonic()+timeout
|
||||
while True:
|
||||
try:
|
||||
evaluate(host,'window.__androidProbe=null; Promise.resolve().then(async()=>('+expression+')).then(value=>window.__androidProbe=JSON.stringify({value}),error=>window.__androidProbe=JSON.stringify({error:String(error),stack:error.stack})); "started"')
|
||||
evaluate(host,'window.__androidProbe=null; Promise.resolve().then(async()=>('+expression+')).then(value=>window.__androidProbe=JSON.stringify({value}),error=>window.__androidProbe=JSON.stringify({error:String(error),stack:error.stack})); "started"',connection)
|
||||
break
|
||||
except (RuntimeError, EOFError, OSError) as e:
|
||||
except RuntimeError as e:
|
||||
if time.monotonic()>=start:raise
|
||||
if isinstance(e, RuntimeError) and not str(e).startswith('App not open:'):raise
|
||||
if not str(e).startswith('App not open:'):raise
|
||||
time.sleep(.3)
|
||||
until=time.monotonic()+timeout
|
||||
while time.monotonic()<until:
|
||||
result=evaluate(host,'window.__androidProbe || "pending"')
|
||||
result=evaluate(host,'window.__androidProbe || "pending"',connection)
|
||||
if isinstance(result,str) and result != 'pending':return json.loads(result)
|
||||
time.sleep(.3)
|
||||
raise TimeoutError(host)
|
||||
|
||||
def run(host,expression,timeout=30):
|
||||
connection=RDP()
|
||||
try:
|
||||
return _run(host,expression,timeout,connection)
|
||||
finally:
|
||||
connection.s.close()
|
||||
|
||||
if __name__=='__main__':
|
||||
print(json.dumps(run(sys.argv[1],sys.argv[2]),ensure_ascii=False,indent=2))
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ class RDP:
|
|||
while True:
|
||||
r = self.recv()
|
||||
self.events.append(r)
|
||||
if str(r.get("type", "")).endswith("ListChanged"):
|
||||
continue
|
||||
if r.get("from") == to and (r.get("type") not in ["consoleAPICall", "pageError"]):
|
||||
return r
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -405,7 +405,7 @@ window.performance.measure('callLogReady','callLogStart');return;}
|
|||
self._empty=false;var currDate=new Date(cursor.value.date);batchGroupCounter++;if(!prevDate||(currDate.getTime()==prevDate.getTime())){chunk.push(cursor.value);}else{daysToRender.push(chunk);chunk=[cursor.value];var renderNow=false;if(batchGroupCounter>=MAX_GROUPS_FOR_FIRST_RENDER&&!screenRendered){renderNow=true;screenRendered=true;window.performance.mark('firstChunkReady');}else if(batchGroupCounter>=MAX_GROUPS_TO_BATCH_RENDER){renderNow=true;}
|
||||
if(renderNow){self.renderSeveralDays(daysToRender);daysToRender=[];batchGroupCounter=0;}}
|
||||
prevDate=currDate;cursor.continue();},'lastEntryDate',true,true);},renderSeveralDays:function cl_renderSeveralDays(chunks){for(var i=0,l=chunks.length;i<l;i++){this.renderChunk(chunks[i]);}},renderChunk:function cl_renderChunk(chunk){var callLogSection=this.createSectionForGroup(chunk[0]);var phoneNumbers=[];var logGroupContainer=callLogSection.getElementsByTagName('ol')[0];for(var i=0;i<chunk.length;i++){var current=chunk[i];var logGroupDOM=this.createGroup(current);logGroupContainer.appendChild(logGroupDOM);phoneNumbers.push(current.number);}
|
||||
this.callLogContainer.appendChild(callLogSection);if(!this._contactCache){this.updateListWithContactInfo(null,null,phoneNumbers,logGroupContainer);}},renderEmptyCallLog:function cl_renderEmptyCallLog(isEmptyMissedCallsGroup){this.disableEditModeButton();if(!isEmptyMissedCallsGroup){this._empty=true;}
|
||||
this.callLogContainer.appendChild(callLogSection);if(!this._contactCache){this.updateListWithContactInfo(null,null,logGroupContainer);}},renderEmptyCallLog:function cl_renderEmptyCallLog(isEmptyMissedCallsGroup){this.disableEditModeButton();if(!isEmptyMissedCallsGroup){this._empty=true;}
|
||||
var noResultContainer=document.getElementById('no-result-container');noResultContainer.hidden=false;var allCallsMsg1=document.getElementById('no-result-msg1');var allCallsMsg2=document.getElementById('no-result-msg2');var noMissedCallsMsg=document.getElementById('no-result-msg3');allCallsMsg1.hidden=isEmptyMissedCallsGroup||false;allCallsMsg2.hidden=isEmptyMissedCallsGroup||false;noMissedCallsMsg.hidden=!isEmptyMissedCallsGroup;if(!isEmptyMissedCallsGroup){var noResultContainerClone=noResultContainer.cloneNode(true);this.callLogContainer.innerHTML='';this.callLogContainer.appendChild(noResultContainerClone);}},appendGroup:function cl_appendGroup(group){if(!this._initialized){return;}
|
||||
this.unfilter();this.enableEditModeButton();var logGroupDOM=this.createGroup(group);var dayIndex=group.date;var container=this.callLogContainer;var previousLogGroup=document.getElementById(group.id);if(previousLogGroup){var parent=previousLogGroup.parentNode;parent.removeChild(previousLogGroup);this.insertInSection(logGroupDOM,parent);return logGroupDOM;}
|
||||
var groupSelector='[data-timestamp="'+dayIndex+'"]';var sectionExists=container.querySelector(groupSelector);if(sectionExists){var section=sectionExists.getElementsByTagName('ol')[0];this.insertInSection(logGroupDOM,section);return logGroupDOM;}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ window.performance.measure('callLogReady','callLogStart');return;}
|
|||
self._empty=false;var currDate=new Date(cursor.value.date);batchGroupCounter++;if(!prevDate||(currDate.getTime()==prevDate.getTime())){chunk.push(cursor.value);}else{daysToRender.push(chunk);chunk=[cursor.value];var renderNow=false;if(batchGroupCounter>=MAX_GROUPS_FOR_FIRST_RENDER&&!screenRendered){renderNow=true;screenRendered=true;window.performance.mark('firstChunkReady');}else if(batchGroupCounter>=MAX_GROUPS_TO_BATCH_RENDER){renderNow=true;}
|
||||
if(renderNow){self.renderSeveralDays(daysToRender);daysToRender=[];batchGroupCounter=0;}}
|
||||
prevDate=currDate;cursor.continue();},'lastEntryDate',true,true);},renderSeveralDays:function cl_renderSeveralDays(chunks){for(var i=0,l=chunks.length;i<l;i++){this.renderChunk(chunks[i]);}},renderChunk:function cl_renderChunk(chunk){var callLogSection=this.createSectionForGroup(chunk[0]);var phoneNumbers=[];var logGroupContainer=callLogSection.getElementsByTagName('ol')[0];for(var i=0;i<chunk.length;i++){var current=chunk[i];var logGroupDOM=this.createGroup(current);logGroupContainer.appendChild(logGroupDOM);phoneNumbers.push(current.number);}
|
||||
this.callLogContainer.appendChild(callLogSection);if(!this._contactCache){this.updateListWithContactInfo(null,null,phoneNumbers,logGroupContainer);}},renderEmptyCallLog:function cl_renderEmptyCallLog(isEmptyMissedCallsGroup){this.disableEditModeButton();if(!isEmptyMissedCallsGroup){this._empty=true;}
|
||||
this.callLogContainer.appendChild(callLogSection);if(!this._contactCache){this.updateListWithContactInfo(null,null,logGroupContainer);}},renderEmptyCallLog:function cl_renderEmptyCallLog(isEmptyMissedCallsGroup){this.disableEditModeButton();if(!isEmptyMissedCallsGroup){this._empty=true;}
|
||||
var noResultContainer=document.getElementById('no-result-container');noResultContainer.hidden=false;var allCallsMsg1=document.getElementById('no-result-msg1');var allCallsMsg2=document.getElementById('no-result-msg2');var noMissedCallsMsg=document.getElementById('no-result-msg3');allCallsMsg1.hidden=isEmptyMissedCallsGroup||false;allCallsMsg2.hidden=isEmptyMissedCallsGroup||false;noMissedCallsMsg.hidden=!isEmptyMissedCallsGroup;if(!isEmptyMissedCallsGroup){var noResultContainerClone=noResultContainer.cloneNode(true);this.callLogContainer.innerHTML='';this.callLogContainer.appendChild(noResultContainerClone);}},appendGroup:function cl_appendGroup(group){if(!this._initialized){return;}
|
||||
this.unfilter();this.enableEditModeButton();var logGroupDOM=this.createGroup(group);var dayIndex=group.date;var container=this.callLogContainer;var previousLogGroup=document.getElementById(group.id);if(previousLogGroup){var parent=previousLogGroup.parentNode;parent.removeChild(previousLogGroup);this.insertInSection(logGroupDOM,parent);return logGroupDOM;}
|
||||
var groupSelector='[data-timestamp="'+dayIndex+'"]';var sectionExists=container.querySelector(groupSelector);if(sectionExists){var section=sectionExists.getElementsByTagName('ol')[0];this.insertInSection(logGroupDOM,section);return logGroupDOM;}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@
|
|||
function labels(){document.getElementById('browse').textContent=text('Ouvrir le catalogue','Open catalogue');document.getElementById('open').textContent=text('Ouvrir','Open');document.getElementById('install').textContent=text('Installer','Install');document.getElementById('cancel').textContent=text('Annuler','Cancel');}
|
||||
function loadFailed(){status(text('Catalogue inaccessible. Vérifiez la connexion Internet, puis réessayez.','Catalogue unavailable. Check your Internet connection, then try again.'));document.getElementById('open').hidden=true;}
|
||||
frame.addEventListener('mozbrowsererror',loadFailed);
|
||||
frame.addEventListener('mozbrowserclose',()=>{
|
||||
if(busy)return;
|
||||
status(text('Le catalogue est fermé. Vous pouvez le rouvrir ci-dessous.',
|
||||
'The catalogue is closed. You can reopen it below.'));
|
||||
document.getElementById('open').hidden=true;
|
||||
});
|
||||
function browse(){labels();message.hidden=true;dialog.hidden=true;frame.style.visibility='visible';frame.src='https://vulpes-os.org/marketplace/?mode=app&lang='+(fr()?'fr':'en');}
|
||||
function status(value){frame.style.visibility='hidden';message.hidden=false;document.getElementById('status').textContent=value;}
|
||||
async function prepare(url){
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"id": "2.7-preview.21",
|
||||
"text": {
|
||||
"fr": "## 14/10/2026 : 2.7-preview.21\n\n\n\n### \n\n-   : Messages des fonctions indisponibles plus lisibles en thèmes clair et sombre.\n-   : 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-   : Affichage du nom des contacts rétabli au premier chargement du journal.\n-   : Panneaux Stockage rétablis. Une erreur de chargement ne bloque plus les autres menus.\n\n### \n\n-   : Lecture des contacts, SMS et journaux Android dans Gaia, après autorisation. Les données locales de Vulpes sont conservées.\n-   : Retour vers Vulpes sans écran blanc persistant ; un catalogue fermé peut être rouvert.\n-   : Le rectangle blanc signalé reste à reproduire sur les appareils concernés.\n-   : 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### \n\n-   : Les corrections communes de Gaia sont incluses dans les images complètes et les mises à jour de démarrage.\n-   : 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\n\n### \n\n-   : Unavailable-feature messages are easier to read in light and dark themes.\n-   : Contact names now appear when the call history first loads.\n-   : Additional dictionaries available offline; improved Gaia keyboard activation and recovery. Verification on affected devices is still needed.\n-   : Storage panels restored. A loading error no longer locks the other menus.\n\n### \n\n-   : Read Android contacts, SMS and call history in Gaia after granting access. Existing local Vulpes data is preserved.\n-   : Return to Vulpes without a persistent white overlay; a closed catalogue can be reopened.\n-   : The reported blank keyboard still needs reproduction on the affected devices.\n-   : 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### \n\n-   : Shared Gaia fixes are included in full installation images and boot updates.\n-   : 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",
|
||||
"text": {
|
||||
|
|
@ -907,6 +926,13 @@
|
|||
"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.",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -265,8 +265,7 @@ var CallLog = {
|
|||
// If the contacts cache is not valid, we retrieve the contacts information
|
||||
// directly from the contacts API and update the DOM accordingly.
|
||||
if (!this._contactCache) {
|
||||
this.updateListWithContactInfo(null, null, phoneNumbers,
|
||||
logGroupContainer);
|
||||
this.updateListWithContactInfo(null, null, logGroupContainer);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -9,10 +9,9 @@ var LayoutDictionaryDownloader = function(imEngineId, dictFilename) {
|
|||
this._xhr = null;
|
||||
};
|
||||
|
||||
// Remote URLs to download the data. We require the remote server to have
|
||||
// CORS turned on since we are not asking for SystemXHR permission.
|
||||
// Dictionaries ship with Vulpes so adding a layout also works offline.
|
||||
LayoutDictionaryDownloader.prototype.REMOTE_URL =
|
||||
'https://fxos.cdn.mozilla.net/dictionaries/%imEngineId/%ver/%dictFilename';
|
||||
'/js/imes/%imEngineId/dictionaries/%dictFilename';
|
||||
|
||||
// If the data schema changes in an incompatible way, we must bump the
|
||||
// version-to-download here.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@
|
|||
function labels(){document.getElementById('browse').textContent=text('Ouvrir le catalogue','Open catalogue');document.getElementById('open').textContent=text('Ouvrir','Open');document.getElementById('install').textContent=text('Installer','Install');document.getElementById('cancel').textContent=text('Annuler','Cancel');}
|
||||
function loadFailed(){status(text('Catalogue inaccessible. Vérifiez la connexion Internet, puis réessayez.','Catalogue unavailable. Check your Internet connection, then try again.'));document.getElementById('open').hidden=true;}
|
||||
frame.addEventListener('mozbrowsererror',loadFailed);
|
||||
frame.addEventListener('mozbrowserclose',()=>{
|
||||
if(busy)return;
|
||||
status(text('Le catalogue est fermé. Vous pouvez le rouvrir ci-dessous.',
|
||||
'The catalogue is closed. You can reopen it below.'));
|
||||
document.getElementById('open').hidden=true;
|
||||
});
|
||||
function browse(){labels();message.hidden=true;dialog.hidden=true;frame.style.visibility='visible';frame.src='https://vulpes-os.org/marketplace/?mode=app&lang='+(fr()?'fr':'en');}
|
||||
function status(value){frame.style.visibility='hidden';message.hidden=false;document.getElementById('status').textContent=value;}
|
||||
async function prepare(url){
|
||||
|
|
|
|||
|
|
@ -86,7 +86,16 @@ define(function(require) {
|
|||
var path = pathElement ? pathElement.dataset.path : null;
|
||||
|
||||
var panelFuncLoaded = function(panelFunc) {
|
||||
var panel = panelFunc();
|
||||
var panel;
|
||||
try {
|
||||
panel = panelFunc();
|
||||
if (!panel || typeof panel.beforeShow !== 'function') {
|
||||
throw new TypeError('Invalid settings panel: ' + panelId);
|
||||
}
|
||||
} catch (error) {
|
||||
if (callback) callback(null, error);
|
||||
return;
|
||||
}
|
||||
_panelCache[panelId] = panel;
|
||||
if (callback) {
|
||||
callback(panel);
|
||||
|
|
@ -97,6 +106,8 @@ define(function(require) {
|
|||
require([path], function(panelFunc) {
|
||||
// Create a new panel object for static panels.
|
||||
panelFuncLoaded(panelFunc ? panelFunc : SettingsPanel);
|
||||
}, function(error) {
|
||||
if (callback) callback(null, error);
|
||||
});
|
||||
} else {
|
||||
panelFuncLoaded(SettingsPanel);
|
||||
|
|
|
|||
|
|
@ -187,7 +187,12 @@ define(function(require) {
|
|||
}
|
||||
|
||||
_loadPanel(panelId, function() {
|
||||
PanelCache.get(panelId, function(panel) {
|
||||
PanelCache.get(panelId, function(panel, loadError) {
|
||||
if (loadError || !panel) {
|
||||
console.error('Settings panel ' + panelId + ':', loadError);
|
||||
callback(loadError || new Error('Missing settings panel: ' + panelId));
|
||||
return;
|
||||
}
|
||||
var newPanelElement = document.getElementById(panelId);
|
||||
var currentPanelId =
|
||||
_currentNavigation && _currentNavigation.panelId;
|
||||
|
|
|
|||
|
|
@ -408,14 +408,13 @@
|
|||
|
||||
var appStarter = AppStarter();
|
||||
|
||||
if (document.readyState !== 'loading') {
|
||||
// Deferred Gaia components contain UMD wrappers. Finish loading them before
|
||||
// Alameda starts, otherwise an anonymous component can become a panel module.
|
||||
if (document.readyState === 'complete') {
|
||||
appStarter.start();
|
||||
} else {
|
||||
document.addEventListener('readystatechange', function readyStateChange() {
|
||||
if (document.readyState == 'interactive') {
|
||||
document.removeEventListener('readystatechange', readyStateChange);
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
appStarter.start();
|
||||
}
|
||||
});
|
||||
}, { once: true });
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -55,8 +55,15 @@
|
|||
ime.dispatchEvent(new Event('inputcontextchange'));
|
||||
};
|
||||
Object.defineProperty(navigator,'mozInputMethod',{value:ime});
|
||||
addEventListener('vulpes-service-event',({detail})=>{if(detail.type==='input-focus')setState(detail.data);});
|
||||
call('input.request',{operation:'state'}).then(setState).catch(console.error);
|
||||
let focusReceived=false;
|
||||
addEventListener('vulpes-service-event',({detail})=>{
|
||||
if(detail.type==='input-focus') {focusReceived=true;setState(detail.data);}
|
||||
});
|
||||
// A focus event can arrive while the initial state request is in flight.
|
||||
// Its older snapshot must not clear or reopen the newer input context.
|
||||
call('input.request',{operation:'state'}).then(state=>{
|
||||
if(!focusReceived)setState(state);
|
||||
}).catch(console.error);
|
||||
window.resizeTo=(_width,height)=>call('input.request',{operation:'height',height}).catch(console.error);
|
||||
}
|
||||
if(app==='system') {
|
||||
|
|
|
|||
|
|
@ -103,3 +103,25 @@
|
|||
background-color: var(--vulpes-surface) !important;
|
||||
color: var(--vulpes-text) !important;
|
||||
}
|
||||
|
||||
/* Keep the message and its surface paired, including lazily loaded Gaia dialogs. */
|
||||
:root #vulpes-gaia-notice,
|
||||
:root #vulpes-gaia-notice > section,
|
||||
:root #vulpes-gaia-notice > section > p {
|
||||
background-color: #f4f4f4 !important;
|
||||
color: #202b33 !important;
|
||||
}
|
||||
:root[data-vulpes-theme="dark"] #vulpes-gaia-notice,
|
||||
:root[data-vulpes-theme="dark"] #vulpes-gaia-notice > section,
|
||||
:root[data-vulpes-theme="dark"] #vulpes-gaia-notice > section > p {
|
||||
background-color: var(--vulpes-surface) !important;
|
||||
color: var(--vulpes-text) !important;
|
||||
}
|
||||
:root[data-vulpes-app="settings"] p[data-vulpes-unavailable] {
|
||||
background-color: #f4f4f4 !important;
|
||||
color: #202b33 !important;
|
||||
}
|
||||
:root[data-vulpes-theme="dark"][data-vulpes-app="settings"] p[data-vulpes-unavailable] {
|
||||
background-color: var(--vulpes-surface) !important;
|
||||
color: var(--vulpes-text) !important;
|
||||
}
|
||||
|
|
|
|||
966
overrides/keyboard.gaiamobile.org/gaia_build_defer_settings.js
Normal file
966
overrides/keyboard.gaiamobile.org/gaia_build_defer_settings.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -223,10 +223,124 @@ break;}
|
|||
var cset={};cset[key]=value;_settings.createLock().set(cset);}};});define('modules/settings_panel',['require','modules/panel','modules/settings_cache','modules/panel_utils'],function(require){'use strict';var Panel=require('modules/panel');var SettingsCache=require('modules/settings_cache');var PanelUtils=require('modules/panel_utils');var _emptyFunc=function panel_emptyFunc(){};var SettingsPanel=function ctor_SettingsPanel(options){var _panel=null;var _settingsChangeHandler=function(event){PanelUtils.onSettingsChange(_panel,event);};var _addListeners=function panel_addListeners(panel){if(!panel){return;}
|
||||
SettingsCache.addEventListener('settingsChange',_settingsChangeHandler);panel.addEventListener('change',PanelUtils.onInputChange);panel.addEventListener('click',PanelUtils.onLinkClick);};var _removeListeners=function panel_removeListeners(panel){if(!panel){return;}
|
||||
SettingsCache.removeEventListener('settingsChange',_settingsChangeHandler);panel.removeEventListener('change',PanelUtils.onInputChange);panel.removeEventListener('click',PanelUtils.onLinkClick);};options=options||{};options.onInit=options.onInit||_emptyFunc;options.onUninit=options.onUninit||_emptyFunc;options.onShow=options.onShow||_emptyFunc;options.onHide=options.onHide||_emptyFunc;options.onBeforeShow=options.onBeforeShow||_emptyFunc;options.onBeforeHide=options.onBeforeHide||_emptyFunc;return Panel({onInit:function(panel,initOptions){if(!panel){return;}
|
||||
_panel=panel;PanelUtils.activate(panel);return options.onInit(panel,initOptions);},onUninit:function(){_removeListeners(_panel);_panel=null;options.onUninit();},onShow:function(panel,showOptions){return options.onShow(panel,showOptions);},onHide:function(){_removeListeners(_panel);return options.onHide();},onBeforeShow:function(panel,beforeShowOptions){PanelUtils.preset(panel);_addListeners(panel);return options.onBeforeShow(panel,beforeShowOptions);},onBeforeHide:function(){return options.onBeforeHide();}});};return SettingsPanel;});define('modules/panel_cache',['require','modules/settings_panel','shared/lazy_loader'],function(require){'use strict';var SettingsPanel=require('modules/settings_panel');var LazyLoader=require('shared/lazy_loader');var _panelCache={};var _panelStylesheetsLoaded=false;var _loadPanelStylesheetsIfNeeded=function loadPanelCSS(){if(_panelStylesheetsLoaded){return;}
|
||||
LazyLoader.load(['shared/style/action_menu.css','shared/style/confirm.css','shared/style/progress_activity.css','shared/elements/gaia-icons/bidi-helper.css','shared/elements/gaia_buttons/script.js','shared/elements/gaia_confirm/script.js','style/homescreens.css','style/apps.css','style/screen_lock.css','style/simcard.css','style/updates.css','style/downloads.css','style/developer_service_workers.css'],function callback(){_panelStylesheetsLoaded=true;});};navigator.addIdleObserver({time:3,onidle:_loadPanelStylesheetsIfNeeded});return{reset:function spc_reset(){_panelCache={};_panelStylesheetsLoaded=false;},get:function spc_get(panelId,callback){if(!panelId&&!callback){return;}
|
||||
if(panelId!=='root'){_loadPanelStylesheetsIfNeeded();}
|
||||
var cachedPanel=_panelCache[panelId];if(cachedPanel){if(callback){callback(cachedPanel);}}else{var panelElement=document.getElementById(panelId);if(panelElement){var pathElement=panelElement.querySelector('panel');var path=pathElement?pathElement.dataset.path:null;var panelFuncLoaded=function(panelFunc){var panel=panelFunc();_panelCache[panelId]=panel;if(callback){callback(panel);}};if(path){require([path],function(panelFunc){panelFuncLoaded(panelFunc?panelFunc:SettingsPanel);});}else{panelFuncLoaded(SettingsPanel);}}else{if(callback){callback(null);}}}}};});var ScreenLayout={defaultQueries:{tiny:'(max-width: 767px)',small:'(min-width: 768px) and (max-width: 991px)',medium:'(min-width: 992px) and (max-width: 1200px)',large:'(min-width: 1201px)',hardwareHomeButton:window.VulpesCompat?'(min-width: 0px)':'(-moz-physical-home-button)'},init:function sl_init(){this.queries=(function(qs){var result={};for(var key in qs){result[key]=window.matchMedia(qs[key]);}
|
||||
_panel=panel;PanelUtils.activate(panel);return options.onInit(panel,initOptions);},onUninit:function(){_removeListeners(_panel);_panel=null;options.onUninit();},onShow:function(panel,showOptions){return options.onShow(panel,showOptions);},onHide:function(){_removeListeners(_panel);return options.onHide();},onBeforeShow:function(panel,beforeShowOptions){PanelUtils.preset(panel);_addListeners(panel);return options.onBeforeShow(panel,beforeShowOptions);},onBeforeHide:function(){return options.onBeforeHide();}});};return SettingsPanel;});define('modules/panel_cache',["require", "modules/settings_panel", "shared/lazy_loader"],function(require) {
|
||||
'use strict';
|
||||
|
||||
var SettingsPanel = require('modules/settings_panel');
|
||||
var LazyLoader = require('shared/lazy_loader');
|
||||
|
||||
var _panelCache = {};
|
||||
var _panelStylesheetsLoaded = false;
|
||||
|
||||
// experiment result shows
|
||||
// load all related styles once is time saver
|
||||
var _loadPanelStylesheetsIfNeeded = function loadPanelCSS() {
|
||||
if (_panelStylesheetsLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
LazyLoader.load(['shared/style/action_menu.css',
|
||||
'shared/style/confirm.css',
|
||||
'shared/style/progress_activity.css',
|
||||
'shared/elements/gaia-icons/bidi-helper.css',
|
||||
'shared/elements/gaia_buttons/script.js',
|
||||
'shared/elements/gaia_confirm/script.js',
|
||||
'style/homescreens.css',
|
||||
// Bug 1210284 - disabling achievements until they are on
|
||||
// the roadmap again or completely removed.
|
||||
// 'style/achievements.css',
|
||||
'style/apps.css',
|
||||
'style/screen_lock.css',
|
||||
'style/simcard.css',
|
||||
'style/updates.css',
|
||||
'style/downloads.css',
|
||||
'style/developer_service_workers.css'],
|
||||
function callback() {
|
||||
_panelStylesheetsLoaded = true;
|
||||
});
|
||||
};
|
||||
|
||||
// load styles in idle time after document loaded
|
||||
navigator.addIdleObserver({
|
||||
time: 3,
|
||||
onidle: _loadPanelStylesheetsIfNeeded
|
||||
});
|
||||
|
||||
return {
|
||||
// this is for unit test
|
||||
reset: function spc_reset() {
|
||||
_panelCache = {};
|
||||
_panelStylesheetsLoaded = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the panel module of a specified id. If there is no corresponding
|
||||
* panel module of the id, it returns SettingsPanel.
|
||||
*
|
||||
* @alias module:PanelCache#get
|
||||
* @param {String} panelId
|
||||
* The id of the to be loaded panel.
|
||||
* @param {Function} callback
|
||||
* The function to be called when the panel is loaded.
|
||||
*/
|
||||
get: function spc_get(panelId, callback) {
|
||||
if (!panelId && !callback) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (panelId !== 'root') {
|
||||
_loadPanelStylesheetsIfNeeded();
|
||||
}
|
||||
|
||||
var cachedPanel = _panelCache[panelId];
|
||||
if (cachedPanel) {
|
||||
if (callback) {
|
||||
callback(cachedPanel);
|
||||
}
|
||||
} else {
|
||||
// Get the path of the panel creation function
|
||||
var panelElement = document.getElementById(panelId);
|
||||
if (panelElement) {
|
||||
var pathElement = panelElement.querySelector('panel');
|
||||
var path = pathElement ? pathElement.dataset.path : null;
|
||||
|
||||
var panelFuncLoaded = function(panelFunc) {
|
||||
var panel;
|
||||
try {
|
||||
panel = panelFunc();
|
||||
if (!panel || typeof panel.beforeShow !== 'function') {
|
||||
throw new TypeError('Invalid settings panel: ' + panelId);
|
||||
}
|
||||
} catch (error) {
|
||||
if (callback) callback(null, error);
|
||||
return;
|
||||
}
|
||||
_panelCache[panelId] = panel;
|
||||
if (callback) {
|
||||
callback(panel);
|
||||
}
|
||||
};
|
||||
|
||||
if (path) {
|
||||
require([path], function(panelFunc) {
|
||||
// Create a new panel object for static panels.
|
||||
panelFuncLoaded(panelFunc ? panelFunc : SettingsPanel);
|
||||
}, function(error) {
|
||||
if (callback) callback(null, error);
|
||||
});
|
||||
} else {
|
||||
panelFuncLoaded(SettingsPanel);
|
||||
}
|
||||
} else {
|
||||
if (callback) {
|
||||
callback(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
var ScreenLayout={defaultQueries:{tiny:'(max-width: 767px)',small:'(min-width: 768px) and (max-width: 991px)',medium:'(min-width: 992px) and (max-width: 1200px)',large:'(min-width: 1201px)',hardwareHomeButton:window.VulpesCompat?'(min-width: 0px)':'(-moz-physical-home-button)'},init:function sl_init(){this.queries=(function(qs){var result={};for(var key in qs){result[key]=window.matchMedia(qs[key]);}
|
||||
return result;})(this.defaultQueries);},_isOnRealDevice:undefined,isOnRealDevice:function sl_isOnRealDevice(){if(typeof(this._isOnRealDevice)!=='undefined'){return this._isOnRealDevice;}
|
||||
if(window.innerWidth===screen.availWidth){this._isOnRealDevice=true;}else{this._isOnRealDevice=false;}
|
||||
return this._isOnRealDevice;},getCurrentLayout:function sl_getCurrentLayout(type){if(type===undefined){for(var name in this.defaultQueries){if(this.queries[name]&&this.queries[name].matches){return name;}}}
|
||||
|
|
@ -443,7 +557,12 @@ define('modules/settings_service',["require", "modules/page_transitions", "modul
|
|||
}
|
||||
|
||||
_loadPanel(panelId, function() {
|
||||
PanelCache.get(panelId, function(panel) {
|
||||
PanelCache.get(panelId, function(panel, loadError) {
|
||||
if (loadError || !panel) {
|
||||
console.error('Settings panel ' + panelId + ':', loadError);
|
||||
callback(loadError || new Error('Missing settings panel: ' + panelId));
|
||||
return;
|
||||
}
|
||||
var newPanelElement = document.getElementById(panelId);
|
||||
var currentPanelId =
|
||||
_currentNavigation && _currentNavigation.panelId;
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
{
|
||||
"gaia": "2.7.0",
|
||||
"channel": "preview",
|
||||
"revision": 20,
|
||||
"androidVersionCode": 20,
|
||||
"revision": 21,
|
||||
"androidVersionCode": 21,
|
||||
"geckoview": "156.0.20260921121718",
|
||||
"engineAlignment": {
|
||||
"family": "156",
|
||||
"patchParity": false,
|
||||
"reason": "Firefox desktop 156.0.1; Mozilla's GeckoView release repository currently offers 156.0 only. Keep the desktop patch and report actual engine versions."
|
||||
},
|
||||
"version": "2.7-preview.20",
|
||||
"version": "2.7-preview.21",
|
||||
"adapters": {
|
||||
"desktop": "0.1.0-dev",
|
||||
"android": "0.1.0-dev"
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import assert from 'node:assert/strict';
|
|||
import vm from 'node:vm';
|
||||
import {readFileSync} from 'node:fs';
|
||||
const source=readFileSync(new URL('../android/extension/content.js',import.meta.url),'utf8');
|
||||
async function setup(enabled=true) {
|
||||
const sent=[],events=new Map();let receive;
|
||||
async function setup(enabled=true,{defer=false,activeElement}={}) {
|
||||
const sent=[],events=new Map(),timers=[];let receive,disconnect;
|
||||
const page={navigator:{},dispatchEvent(){}};
|
||||
const port={postMessage:m=>sent.push(m),onMessage:{addListener:fn=>receive=fn},onDisconnect:{addListener(){}}};
|
||||
const port={postMessage:m=>sent.push(m),onMessage:{addListener:fn=>receive=fn},onDisconnect:{addListener:fn=>disconnect=fn}};
|
||||
class Editor {
|
||||
constructor(){this.editing=false;}
|
||||
focus(e){this.inputElement=e;}
|
||||
|
|
@ -14,14 +14,14 @@ async function setup(enabled=true) {
|
|||
edit(){return this.state();}
|
||||
}
|
||||
const context={InputEditor:Editor,browser:{runtime:{connect:()=>port}},console,
|
||||
location:{port:'18765',hostname:'sms.localhost'},document:{hidden:false},
|
||||
location:{port:'18765',hostname:'sms.localhost'},document:{hidden:false,activeElement,hasFocus:()=>true},
|
||||
window:{wrappedJSObject:page,Promise,Error,Event,CustomEvent:class extends Event{constructor(name,p){super(name);this.detail=p.detail;}}},
|
||||
cloneInto:x=>x,exportFunction:(fn,obj,p)=>{obj[p.defineAs]=fn;},
|
||||
addEventListener:(name,fn)=>events.set(name,fn),setTimeout,clearTimeout};
|
||||
addEventListener:(name,fn)=>events.set(name,fn),setTimeout:fn=>timers.push(fn),clearTimeout(){}};
|
||||
vm.runInNewContext(source,context);
|
||||
receive({id:sent[0].id,reply:{result:enabled}});
|
||||
if(!defer) receive({id:sent[0].id,reply:{result:enabled}});
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
return {sent,events,page,receive,context};
|
||||
return {sent,events,page,receive,context,timers,disconnect};
|
||||
}
|
||||
function field(mode='numeric') {
|
||||
const attrs=new Map([['inputmode',mode]]);
|
||||
|
|
@ -51,3 +51,54 @@ test('Android keyboard remains the default and hidden Gaia restores the editor m
|
|||
assert.equal(e.getAttribute('inputmode'),'numeric');
|
||||
assert.equal(s.sent.at(-1).inputFocus,null);
|
||||
});
|
||||
|
||||
test('a field focused before the preference arrives activates Gaia without another tap',async()=>{
|
||||
const e=field(),s=await setup(true,{defer:true,activeElement:e});
|
||||
s.events.get('focusin')({target:e});
|
||||
assert.equal(s.sent.length,1);
|
||||
s.receive({id:s.sent[0].id,reply:{result:true}});
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(e.getAttribute('inputmode'),'none');
|
||||
assert.equal(s.sent.at(-1).inputFocus.inputMode,'numeric');
|
||||
});
|
||||
|
||||
test('an old preference reply cannot undo a newer keyboard toggle',async()=>{
|
||||
const e=field(),s=await setup(false,{defer:true,activeElement:e});
|
||||
s.receive({event:{type:'input-mode',data:{enabled:true}}});
|
||||
s.receive({id:s.sent[0].id,reply:{result:false}});
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(s.page.VulpesNativeInput,true);
|
||||
assert.equal(e.getAttribute('inputmode'),'none');
|
||||
});
|
||||
|
||||
test('returning to an app restores its focused Gaia editor',async()=>{
|
||||
const e=field(),s=await setup(true,{activeElement:e});
|
||||
s.context.document.hidden=true;s.events.get('visibilitychange')();
|
||||
assert.equal(s.sent.at(-1).inputFocus,null);
|
||||
s.context.document.hidden=false;s.events.get('visibilitychange')();
|
||||
assert.equal(s.sent.at(-1).inputFocus.inputMode,'numeric');
|
||||
assert.equal(e.getAttribute('inputmode'),'none');
|
||||
});
|
||||
|
||||
test('bridge reconnection rereads the preference and restores the active editor',async()=>{
|
||||
const e=field(),s=await setup(true,{activeElement:e});
|
||||
s.disconnect();
|
||||
assert.equal(e.getAttribute('inputmode'),'numeric');
|
||||
assert.equal(s.page.VulpesNativeInput,false);
|
||||
s.timers.at(-1)();
|
||||
const request=s.sent.at(-1);
|
||||
assert.equal(request.request.method,'input.config');
|
||||
s.receive({id:request.id,reply:{result:true}});
|
||||
await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.equal(e.getAttribute('inputmode'),'none');
|
||||
assert.equal(s.sent.at(-1).inputFocus.inputMode,'numeric');
|
||||
});
|
||||
|
||||
test('a background app cannot reclaim the keyboard when Android resumes',async()=>{
|
||||
const e=field(),s=await setup(true,{activeElement:e});
|
||||
s.context.document.hidden=true;s.events.get('visibilitychange')();
|
||||
s.context.document.hidden=false;s.context.document.hasFocus=()=>false;
|
||||
s.events.get('visibilitychange')();
|
||||
assert.equal(s.sent.at(-1).inputFocus,null);
|
||||
assert.equal(e.getAttribute('inputmode'),'numeric');
|
||||
});
|
||||
|
|
|
|||
59
tests/android-personal-data.test.mjs
Normal file
59
tests/android-personal-data.test.mjs
Normal 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/);
|
||||
});
|
||||
85
tests/android-personal-ui.test.mjs
Normal file
85
tests/android-personal-ui.test.mjs
Normal 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')();}
|
||||
});
|
||||
20
tests/call-log-contacts.test.mjs
Normal file
20
tests/call-log-contacts.test.mjs
Normal 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']);
|
||||
});
|
||||
}
|
||||
24
tests/keyboard-dictionaries.test.mjs
Normal file
24
tests/keyboard-dictionaries.test.mjs
Normal 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);
|
||||
});
|
||||
40
tests/keyboard-startup.test.mjs
Normal file
40
tests/keyboard-startup.test.mjs
Normal 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');
|
||||
});
|
||||
34
tests/marketplace-navigation.test.mjs
Normal file
34
tests/marketplace-navigation.test.mjs
Normal 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);
|
||||
});
|
||||
29
tests/settings-panel-cache.test.mjs
Normal file
29
tests/settings-panel-cache.test.mjs
Normal 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');});
|
||||
});
|
||||
19
tests/settings-startup.test.mjs
Normal file
19
tests/settings-startup.test.mjs
Normal 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);
|
||||
});
|
||||
|
|
@ -74,7 +74,7 @@ class ChangelogTests(unittest.TestCase):
|
|||
|
||||
def test_date_first_heading_and_platform_badges(self):
|
||||
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['date'], '2026-10-13')
|
||||
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):
|
||||
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)
|
||||
self.assertIn('A new correction.', docs['release-body.md'])
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"""Reject stale Android payloads and inconsistent common release metadata."""
|
||||
|
||||
import argparse, contextlib, hashlib, io, json, re, zipfile
|
||||
from project import ROOT, release_info
|
||||
from project import ROOT, release_info, SOURCE_OVERRIDES
|
||||
|
||||
p = argparse.ArgumentParser()
|
||||
group = p.add_mutually_exclusive_group()
|
||||
|
|
@ -72,6 +72,7 @@ with source as archive:
|
|||
assert actual == expected.encode(), "Stale Android Gaia panel: " + path
|
||||
results[app + "/" + path] = hashlib.sha256(actual).hexdigest()
|
||||
for app, path, source in [
|
||||
("keyboard", "gaia_build_defer_settings.js", "overrides"),
|
||||
("calendar", "gaia_build_defer_index.js", "overrides"),
|
||||
("calendar", "js/bundle.js", "overrides"),
|
||||
("gallery", "js/metadata_scripts.js", "overrides"),
|
||||
|
|
@ -94,6 +95,13 @@ with source as archive:
|
|||
actual = gaia.read(app + "/" + path)
|
||||
assert actual == expected, "Stale application workflow: " + app + "/" + path
|
||||
results[app + "/" + path] = hashlib.sha256(actual).hexdigest()
|
||||
for app in ("keyboard", "settings"):
|
||||
for name in SOURCE_OVERRIDES[app]:
|
||||
expected = (ROOT / "gaia/apps" / app / name).read_bytes()
|
||||
if name.endswith(".js"):
|
||||
expected = translate(expected.decode()).encode()
|
||||
assert gaia.read(app + "/" + name) == expected, "Stale shared source: " + app + "/" + name
|
||||
results[app + "/" + name] = hashlib.sha256(expected).hexdigest()
|
||||
helper = "dialer/js/telephony_helper.js"
|
||||
expected = translate((ROOT / "gaia/apps/communications" / helper).read_text()).encode()
|
||||
assert gaia.read("communications/" + helper) == expected, "Stale call-log dial helper"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ SOURCE_OVERRIDES = {
|
|||
"system": ["js/launcher.js", "js/gaia_scheduler.js", "js/keyboard_manager.js",
|
||||
"js/core.js", "js/rocketbar.js"],
|
||||
"communications": ["dialer/js/telephony_helper.js"],
|
||||
"settings": ["js/modules/storage_helper.js", "js/startup.js"],
|
||||
"keyboard": ["js/settings/layout_dictionary_downloader.js"] + [
|
||||
str(path.relative_to(ROOT / "gaia/apps/keyboard"))
|
||||
for path in sorted((ROOT / "gaia/apps/keyboard/js/imes/latin/dictionaries").glob("*.dict"))
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
13
tools/refresh-keyboard.py
Normal file
13
tools/refresh-keyboard.py
Normal 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))
|
||||
|
|
@ -12,6 +12,7 @@ modules = [
|
|||
('panels/wifi/wifi_network_list', 'js/panels/wifi/panel.js'),
|
||||
('modules/page_transitions', 'js/main.js'),
|
||||
('modules/settings_service', 'js/main.js'),
|
||||
('modules/panel_cache', 'js/main.js'),
|
||||
('modules/dialog_service', 'js/modules/dialog_service.js'),
|
||||
('modules/dialog_manager', 'js/modules/dialog_service.js'),
|
||||
('modules/date_time', 'js/modules/date_time.js'),
|
||||
|
|
|
|||
48
tools/test-ticket-settings.py
Normal file
48
tools/test-ticket-settings.py
Normal 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}))
|
||||
Loading…
Reference in a new issue