Compare commits
44 changed files with 975 additions and 3860 deletions
1077
CHANGELOG.en.md
1077
CHANGELOG.en.md
File diff suppressed because it is too large
Load diff
1077
CHANGELOG.md
1077
CHANGELOG.md
File diff suppressed because it is too large
Load diff
|
|
@ -1,9 +1,5 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
|
||||||
<uses-permission android:name="android.permission.READ_SMS" />
|
|
||||||
<uses-permission android:name="android.permission.READ_CALL_LOG" />
|
|
||||||
<uses-feature android:name="android.hardware.telephony" android:required="false" />
|
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
|
||||||
|
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
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,7 +24,6 @@ final class AndroidServices {
|
||||||
CONTACT = 412;
|
CONTACT = 412;
|
||||||
static final long MAX_MEDIA = 32L * 1024 * 1024;
|
static final long MAX_MEDIA = 32L * 1024 * 1024;
|
||||||
final VulpesApplication app;
|
final VulpesApplication app;
|
||||||
final AndroidPersonalData personal;
|
|
||||||
final ExecutorService io = Executors.newSingleThreadExecutor();
|
final ExecutorService io = Executors.newSingleThreadExecutor();
|
||||||
final Handler main = new Handler(Looper.getMainLooper());
|
final Handler main = new Handler(Looper.getMainLooper());
|
||||||
GeckoResult<Object> pending;
|
GeckoResult<Object> pending;
|
||||||
|
|
@ -32,7 +31,6 @@ final class AndroidServices {
|
||||||
|
|
||||||
AndroidServices(VulpesApplication app) {
|
AndroidServices(VulpesApplication app) {
|
||||||
this.app = app;
|
this.app = app;
|
||||||
personal = new AndroidPersonalData(app);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
GeckoResult<Object> request(JSONObject message) {
|
GeckoResult<Object> request(JSONObject message) {
|
||||||
|
|
@ -42,30 +40,6 @@ final class AndroidServices {
|
||||||
JSONObject p = message.optJSONObject("params");
|
JSONObject p = message.optJSONObject("params");
|
||||||
if (p == null) p = new JSONObject();
|
if (p == null) p = new JSONObject();
|
||||||
switch (op) {
|
switch (op) {
|
||||||
case "android.personal":
|
|
||||||
JSONObject personalParams = p;
|
|
||||||
String operation = p.optString("operation");
|
|
||||||
if (operation.equals("permission")) {
|
|
||||||
String kind = p.getString("kind");
|
|
||||||
activity().permission(new String[]{AndroidPersonalData.permission(kind)}, granted -> {
|
|
||||||
try { personal.enable(kind, granted); result.complete(personal.status()); } catch (Exception e) { fail(result, e); }
|
|
||||||
});
|
|
||||||
} else if (operation.equals("status")) result.complete(personal.status());
|
|
||||||
else if (operation.equals("disconnect")) {
|
|
||||||
personal.enable(p.getString("kind"), false);
|
|
||||||
result.complete(personal.status());
|
|
||||||
}
|
|
||||||
else if (operation.equals("open")) {
|
|
||||||
activity().startActivity(personal.open(p));
|
|
||||||
result.complete(null);
|
|
||||||
} else if (operation.equals("page")) io.execute(() -> {
|
|
||||||
try {
|
|
||||||
JSONObject data = personal.page(personalParams);
|
|
||||||
main.post(() -> result.complete(data));
|
|
||||||
} catch (Exception e) { fail(result, e); }
|
|
||||||
});
|
|
||||||
else throw new IllegalArgumentException("METHOD_NOT_SUPPORTED");
|
|
||||||
break;
|
|
||||||
case "android.notification":
|
case "android.notification":
|
||||||
JSONObject request = p;
|
JSONObject request = p;
|
||||||
if (p.optString("operation").equals("permission")) {
|
if (p.optString("operation").equals("permission")) {
|
||||||
|
|
|
||||||
|
|
@ -82,8 +82,8 @@ public final class MainActivity extends Activity {
|
||||||
webPanel.addView(nav, new LinearLayout.LayoutParams(-1, dp(52)));
|
webPanel.addView(nav, new LinearLayout.LayoutParams(-1, dp(52)));
|
||||||
Button back = new Button(this);
|
Button back = new Button(this);
|
||||||
back.setText("‹");
|
back.setText("‹");
|
||||||
back.setContentDescription("Retour");
|
back.setContentDescription("Retour au bureau");
|
||||||
back.setOnClickListener(v -> back());
|
back.setOnClickListener(v -> closeBrowser());
|
||||||
nav.addView(back, new LinearLayout.LayoutParams(dp(55), -1));
|
nav.addView(back, new LinearLayout.LayoutParams(dp(55), -1));
|
||||||
address = new EditText(this);
|
address = new EditText(this);
|
||||||
address.setSingleLine(true);
|
address.setSingleLine(true);
|
||||||
|
|
@ -99,9 +99,6 @@ public final class MainActivity extends Activity {
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
webView = new GeckoView(this);
|
webView = new GeckoView(this);
|
||||||
// The web panel is layered over Gaia and hidden when returning to an app.
|
|
||||||
// A TextureView follows that visibility without leaving a separate surface.
|
|
||||||
webView.setViewBackend(GeckoView.BACKEND_TEXTURE_VIEW);
|
|
||||||
webPanel.addView(webView, new LinearLayout.LayoutParams(-1, 0, 1));
|
webPanel.addView(webView, new LinearLayout.LayoutParams(-1, 0, 1));
|
||||||
home = new Button(this);
|
home = new Button(this);
|
||||||
home.setText("◯");
|
home.setText("◯");
|
||||||
|
|
@ -300,12 +297,10 @@ public final class MainActivity extends Activity {
|
||||||
}
|
}
|
||||||
|
|
||||||
void closeBrowser() {
|
void closeBrowser() {
|
||||||
boolean wasVisible = webPanel.getVisibility() == View.VISIBLE;
|
|
||||||
webPanel.setVisibility(View.GONE);
|
webPanel.setVisibility(View.GONE);
|
||||||
gaiaView.setVisibility(View.VISIBLE);
|
gaiaView.setVisibility(View.VISIBLE);
|
||||||
setHomeSurface(app.homeVisible);
|
setHomeSurface(app.homeVisible);
|
||||||
syncActive();
|
syncActive();
|
||||||
if (wasVisible) app.browserClosed();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void back() {
|
private void back() {
|
||||||
|
|
@ -475,7 +470,7 @@ public final class MainActivity extends Activity {
|
||||||
else callback.accept(granted);
|
else callback.accept(granted);
|
||||||
}
|
}
|
||||||
|
|
||||||
void permission(String[] names, java.util.function.Consumer<Boolean> callback) {
|
private void permission(String[] names, java.util.function.Consumer<Boolean> callback) {
|
||||||
boolean granted = true;
|
boolean granted = true;
|
||||||
for (String name : names)
|
for (String name : names)
|
||||||
granted &= checkSelfPermission(name) == PackageManager.PERMISSION_GRANTED;
|
granted &= checkSelfPermission(name) == PackageManager.PERMISSION_GRANTED;
|
||||||
|
|
|
||||||
|
|
@ -150,15 +150,6 @@ public final class VulpesApplication extends Application {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void browserClosed() {
|
|
||||||
if (port == null) return;
|
|
||||||
try {
|
|
||||||
port.postMessage(new JSONObject().put("type", "browser-closed"));
|
|
||||||
} catch (Exception e) {
|
|
||||||
Log.w("Vulpes", "Browser close notification", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void home(boolean held) {
|
void home(boolean held) {
|
||||||
if (port == null) return;
|
if (port == null) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -169,7 +160,6 @@ public final class VulpesApplication extends Application {
|
||||||
}
|
}
|
||||||
|
|
||||||
void resumed() {
|
void resumed() {
|
||||||
if (services != null) services.personal.observe();
|
|
||||||
sendScreenLock();
|
sendScreenLock();
|
||||||
if (port == null) return;
|
if (port == null) return;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import {AndroidPersonalData, authorizePersonal} from './personal-data.mjs';
|
|
||||||
import {Input} from './services/input.mjs';
|
import {Input} from './services/input.mjs';
|
||||||
import { Packages, packageURL } from './services/packages.mjs';
|
import { Packages, packageURL } from './services/packages.mjs';
|
||||||
import { Activities, isGaiaActivity } from './services/activities.mjs';
|
import { Activities, isGaiaActivity } from './services/activities.mjs';
|
||||||
|
|
@ -16,7 +15,7 @@ const input = new Input({identity:peer=>peer.app,broadcast});
|
||||||
let editSequence=0;
|
let editSequence=0;
|
||||||
const nativePackages=(operation,data={})=>browser.runtime.sendNativeMessage('vulpes',{type:'packages.'+operation,...data}).then(JSON.parse);
|
const nativePackages=(operation,data={})=>browser.runtime.sendNativeMessage('vulpes',{type:'packages.'+operation,...data}).then(JSON.parse);
|
||||||
async function refreshInstalled(){const installed=await nativePackages('list');apps.splice(0,apps.length,...apps.filter(a=>!a.installed),...installed);}
|
async function refreshInstalled(){const installed=await nativePackages('list');apps.splice(0,apps.length,...apps.filter(a=>!a.installed),...installed);}
|
||||||
let apps, values, settings, dataStores, contacts, messages, connections, alarms, storage, personal;
|
let apps, values, settings, dataStores, contacts, messages, connections, alarms, storage;
|
||||||
const identity = (peer) => peer.app;
|
const identity = (peer) => peer.app;
|
||||||
function notify(peer, type, data) {
|
function notify(peer, type, data) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -122,7 +121,6 @@ const ready = (async () => {
|
||||||
contacts = new Contacts(storage, (data) => broadcast('contactchange', data));
|
contacts = new Contacts(storage, (data) => broadcast('contactchange', data));
|
||||||
await contacts.init();
|
await contacts.init();
|
||||||
messages = new Messages(storage);
|
messages = new Messages(storage);
|
||||||
personal = new AndroidPersonalData(params=>android("personal",params), contacts, messages, storage);
|
|
||||||
dataStores = new DataStoreService(apps, storage, (data) => broadcast('datastorechange', data));
|
dataStores = new DataStoreService(apps, storage, (data) => broadcast('datastorechange', data));
|
||||||
connections = new Connections(identity, notify);
|
connections = new Connections(identity, notify);
|
||||||
alarms = new Alarms(
|
alarms = new Alarms(
|
||||||
|
|
@ -142,7 +140,6 @@ const ready = (async () => {
|
||||||
})();
|
})();
|
||||||
// Java owns this port. Web pages have no access to native messaging.
|
// Java owns this port. Web pages have no access to native messaging.
|
||||||
let native;
|
let native;
|
||||||
let activeWebView;
|
|
||||||
let systemReady = false;
|
let systemReady = false;
|
||||||
let pendingScreenLock = false;
|
let pendingScreenLock = false;
|
||||||
function deliverScreenLock() {
|
function deliverScreenLock() {
|
||||||
|
|
@ -166,26 +163,15 @@ function connectNative() {
|
||||||
native.onDisconnect.addListener(() => setTimeout(connectNative, 1000));
|
native.onDisconnect.addListener(() => setTimeout(connectNative, 1000));
|
||||||
native.onMessage.addListener(async (message) => {
|
native.onMessage.addListener(async (message) => {
|
||||||
await ready;
|
await ready;
|
||||||
if (message.type === 'browser-closed' && activeWebView) {
|
|
||||||
const {peer, contextId} = activeWebView;
|
|
||||||
activeWebView = null;
|
|
||||||
notify(peer, 'view', {contextId, type:'close', detail:{}});
|
|
||||||
}
|
|
||||||
if (['home', 'holdhome'].includes(message.type)) broadcast(message.type, {}, 'system');
|
if (['home', 'holdhome'].includes(message.type)) broadcast(message.type, {}, 'system');
|
||||||
if (message.type === 'android-screen-off') {
|
if (message.type === 'android-screen-off') {
|
||||||
pendingScreenLock = true;
|
pendingScreenLock = true;
|
||||||
deliverScreenLock();
|
deliverScreenLock();
|
||||||
}
|
}
|
||||||
if (message.type === 'android-data-changed') {
|
|
||||||
broadcast('android-data-changed', {kind:message.kind}, message.kind === 'sms' ? 'sms' : 'communications');
|
|
||||||
}
|
|
||||||
if (message.type === 'android-resume') {
|
if (message.type === 'android-resume') {
|
||||||
deliverScreenLock();
|
deliverScreenLock();
|
||||||
broadcast('screen-on', {}, 'system');
|
broadcast('screen-on', {}, 'system');
|
||||||
await refreshAndroid().catch(console.warn);
|
await refreshAndroid().catch(console.warn);
|
||||||
broadcast('android-data-changed', {kind:'contacts'}, 'communications');
|
|
||||||
broadcast('android-data-changed', {kind:'calls'}, 'communications');
|
|
||||||
broadcast('android-data-changed', {kind:'sms'}, 'sms');
|
|
||||||
}
|
}
|
||||||
if (message.type === 'notification-click') {
|
if (message.type === 'notification-click') {
|
||||||
pendingNotification = message.data;
|
pendingNotification = message.data;
|
||||||
|
|
@ -229,12 +215,6 @@ async function handle(peer, request) {
|
||||||
authorizePlatform(app, method.slice(9), params);
|
authorizePlatform(app, method.slice(9), params);
|
||||||
method = 'android.' + method.slice(9);
|
method = 'android.' + method.slice(9);
|
||||||
}
|
}
|
||||||
if (method === 'android.personal') {
|
|
||||||
authorizePersonal(app, params.kind, params.operation);
|
|
||||||
if (params.operation === 'page') return personal.rows(params.kind);
|
|
||||||
if (!['status','permission','disconnect','open'].includes(params.operation)) throw Error('METHOD_NOT_SUPPORTED');
|
|
||||||
return android('personal', params);
|
|
||||||
}
|
|
||||||
if (method === 'android.capabilities') {
|
if (method === 'android.capabilities') {
|
||||||
const release = await (await fetch('release.json')).json();
|
const release = await (await fetch('release.json')).json();
|
||||||
const device = app.id === 'settings' ? await android('snapshot') : {};
|
const device = app.id === 'settings' ? await android('snapshot') : {};
|
||||||
|
|
@ -375,8 +355,8 @@ async function handle(peer, request) {
|
||||||
}
|
}
|
||||||
if (method === 'datastores.list') return dataStores.list(app, params.name);
|
if (method === 'datastores.list') return dataStores.list(app, params.name);
|
||||||
if (method === 'datastores.run') return dataStores.run(app, params);
|
if (method === 'datastores.run') return dataStores.run(app, params);
|
||||||
if (method === 'contacts.request') return personal.contactRequest(app, params);
|
if (method === 'contacts.request') return contacts.request(app, params);
|
||||||
if (method === 'messages.request') return personal.messageRequest(app, params);
|
if (method === 'messages.request') return messages.request(app, params);
|
||||||
if (method === 'alarms.request') return alarms.request(peer, app, params);
|
if (method === 'alarms.request') return alarms.request(peer, app, params);
|
||||||
if (method === 'iac.subscribe') {
|
if (method === 'iac.subscribe') {
|
||||||
connections.subscribe(peer);
|
connections.subscribe(peer);
|
||||||
|
|
@ -417,7 +397,6 @@ async function handle(peer, request) {
|
||||||
throw Error('PERMISSION_DENIED');
|
throw Error('PERMISSION_DENIED');
|
||||||
if (params.operation === 'open') {
|
if (params.operation === 'open') {
|
||||||
await openWeb(params.url);
|
await openWeb(params.url);
|
||||||
activeWebView = {peer, contextId:params.contextId};
|
|
||||||
notify(peer, 'view', {
|
notify(peer, 'view', {
|
||||||
contextId: params.contextId,
|
contextId: params.contextId,
|
||||||
type: 'loadend',
|
type: 'loadend',
|
||||||
|
|
@ -523,7 +502,6 @@ browser.runtime.onConnect.addListener((port) => {
|
||||||
attached
|
attached
|
||||||
.then(() => {
|
.then(() => {
|
||||||
peers.delete(peer);
|
peers.delete(peer);
|
||||||
if (activeWebView?.peer === peer) activeWebView = null;
|
|
||||||
input.cleanup(peer);
|
input.cleanup(peer);
|
||||||
for(const edit of peer.edits.values()) {clearTimeout(edit.timer);edit.reject(Error('INPUT_CONTEXT_EXPIRED'));}
|
for(const edit of peer.edits.values()) {clearTimeout(edit.timer);edit.reject(Error('INPUT_CONTEXT_EXPIRED'));}
|
||||||
peer.edits.clear();
|
peer.edits.clear();
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,10 @@
|
||||||
}
|
}
|
||||||
if (message.event) {
|
if (message.event) {
|
||||||
if(message.event.type==='input-mode') {
|
if(message.event.type==='input-mode') {
|
||||||
inputConfigurationRevision++;
|
gaiaKeyboard=message.event.data.enabled===true;
|
||||||
configureInput(message.event.data.enabled===true);
|
if(!gaiaKeyboard) restoreMode();
|
||||||
|
win.VulpesNativeInput=gaiaKeyboard;
|
||||||
|
win.dispatchEvent(new window.Event('vulpes-input-configured'));
|
||||||
}
|
}
|
||||||
win.dispatchEvent(
|
win.dispatchEvent(
|
||||||
new window.CustomEvent('vulpes-service-event', { detail: cloneInto(message.event, win) }),
|
new window.CustomEvent('vulpes-service-event', { detail: cloneInto(message.event, win) }),
|
||||||
|
|
@ -51,8 +53,6 @@
|
||||||
callback.resolve(cloneInto(message.reply, win));
|
callback.resolve(cloneInto(message.reply, win));
|
||||||
}
|
}
|
||||||
function onDisconnect() {
|
function onDisconnect() {
|
||||||
inputConfigurationRevision++;
|
|
||||||
configureInput(false);
|
|
||||||
for (const callback of pending.values())
|
for (const callback of pending.values())
|
||||||
callback.reject(new window.Error('VULPES_HOST_CLOSED'));
|
callback.reject(new window.Error('VULPES_HOST_CLOSED'));
|
||||||
pending.clear();
|
pending.clear();
|
||||||
|
|
@ -60,15 +60,14 @@
|
||||||
reconnect = setTimeout(() => {
|
reconnect = setTimeout(() => {
|
||||||
try {
|
try {
|
||||||
connect();
|
connect();
|
||||||
refreshInputConfiguration();
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
onDisconnect();
|
onDisconnect();
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
connect();
|
||||||
const editor = new InputEditor(window);
|
const editor = new InputEditor(window);
|
||||||
let gaiaKeyboard = false;
|
let gaiaKeyboard = false;
|
||||||
let inputConfigurationRevision = 0;
|
|
||||||
const originalModes = new WeakMap();
|
const originalModes = new WeakMap();
|
||||||
function restoreMode() {
|
function restoreMode() {
|
||||||
const field = editor.inputElement;
|
const field = editor.inputElement;
|
||||||
|
|
@ -78,27 +77,21 @@
|
||||||
else field.setAttribute('inputmode', value);
|
else field.setAttribute('inputmode', value);
|
||||||
originalModes.delete(field);
|
originalModes.delete(field);
|
||||||
}
|
}
|
||||||
function configureInput(enabled) {
|
request({version:1,id:0,method:'input.config',params:{}}).then(reply => {
|
||||||
gaiaKeyboard = enabled;
|
gaiaKeyboard = reply.result === true;
|
||||||
if(!enabled) restoreMode();
|
|
||||||
win.VulpesNativeInput = gaiaKeyboard;
|
win.VulpesNativeInput = gaiaKeyboard;
|
||||||
win.dispatchEvent(new window.Event('vulpes-input-configured'));
|
win.dispatchEvent(new window.Event('vulpes-input-configured'));
|
||||||
if(enabled && !document.hidden && document.hasFocus()) reportFocus(document.activeElement);
|
}).catch(error => console.error('Gaia input configuration:', error));
|
||||||
}
|
for (const type of ['focusin', 'pointerdown', 'click']) addEventListener(type, event => {
|
||||||
function refreshInputConfiguration() {
|
if (!gaiaKeyboard || editor.editing || location.hostname === 'keyboard.localhost') return;
|
||||||
const revision = inputConfigurationRevision;
|
let element = event.target;
|
||||||
request({version:1,id:0,method:'input.config',params:{}}).then(reply => {
|
|
||||||
if(revision===inputConfigurationRevision) configureInput(reply.result===true);
|
|
||||||
}).catch(error => console.error('Gaia input configuration:', error));
|
|
||||||
}
|
|
||||||
function reportFocus(element) {
|
|
||||||
if (!gaiaKeyboard || editor.editing || location.hostname === 'keyboard.localhost') return false;
|
|
||||||
if (element?.isContentEditable) element=element.closest('[contenteditable=true]') || element;
|
if (element?.isContentEditable) element=element.closest('[contenteditable=true]') || element;
|
||||||
const editable = element?.isContentEditable ||
|
const editable = element?.isContentEditable ||
|
||||||
(['input','textarea'].includes(element?.localName) &&
|
(['input','textarea'].includes(element?.localName) &&
|
||||||
!['button','submit','range','checkbox','radio','date','time','datetime-local','file'].includes(element.type));
|
!['button','submit','range','checkbox','radio','date','time','datetime-local','file'].includes(element.type));
|
||||||
if (!editable || element.disabled || element.readOnly) {
|
if (!editable || element.disabled || element.readOnly) {
|
||||||
return false;
|
if (type === 'click' && !element?.closest?.('[data-vulpes-keep-input]')) port.postMessage({inputFocus:null});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (element !== editor.inputElement) restoreMode();
|
if (element !== editor.inputElement) restoreMode();
|
||||||
editor.focus(element);
|
editor.focus(element);
|
||||||
|
|
@ -108,18 +101,9 @@
|
||||||
const state=editor.state();
|
const state=editor.state();
|
||||||
state.inputMode=originalModes.get(element) || (element.classList.contains('recipient')?'tel':'');
|
state.inputMode=originalModes.get(element) || (element.classList.contains('recipient')?'tel':'');
|
||||||
port.postMessage({inputFocus:state});
|
port.postMessage({inputFocus:state});
|
||||||
return true;
|
|
||||||
}
|
|
||||||
connect();
|
|
||||||
refreshInputConfiguration();
|
|
||||||
for (const type of ['focusin', 'pointerdown', 'click']) addEventListener(type, event => {
|
|
||||||
if (!gaiaKeyboard || editor.editing || location.hostname === 'keyboard.localhost') return;
|
|
||||||
if (!reportFocus(event.target) && type === 'click' &&
|
|
||||||
!event.target?.closest?.('[data-vulpes-keep-input]')) port.postMessage({inputFocus:null});
|
|
||||||
}, true);
|
}, true);
|
||||||
addEventListener('visibilitychange', () => {
|
addEventListener('visibilitychange', () => {
|
||||||
if (document.hidden && gaiaKeyboard) { restoreMode(); port.postMessage({inputFocus:null}); }
|
if (document.hidden && gaiaKeyboard) { restoreMode(); port.postMessage({inputFocus:null}); }
|
||||||
else if (gaiaKeyboard && document.hasFocus()) reportFocus(document.activeElement);
|
|
||||||
});
|
});
|
||||||
addEventListener('unload', () => clearTimeout(reconnect));
|
addEventListener('unload', () => clearTimeout(reconnect));
|
||||||
exportFunction(request, win.navigator, { defineAs: 'vulpesRequest' });
|
exportFunction(request, win.navigator, { defineAs: 'vulpesRequest' });
|
||||||
|
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
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,15 +3,14 @@
|
||||||
import json,sys,time
|
import json,sys,time
|
||||||
from rdp import RDP
|
from rdp import RDP
|
||||||
|
|
||||||
def evaluate(host, script, connection=None):
|
def evaluate(host, script):
|
||||||
hostname, _, path = host.partition('/')
|
r=RDP()
|
||||||
r=connection or RDP()
|
|
||||||
try:
|
try:
|
||||||
for process in r.call('root','listProcesses')['processes']:
|
for process in r.call('root','listProcesses')['processes']:
|
||||||
if process['isParent']: continue
|
if process['isParent']: continue
|
||||||
target=r.call(process['actor'],'getTarget')['process']
|
target=r.call(process['actor'],'getTarget')['process']
|
||||||
if 'webIsolated' not in target.get('remoteType',''): continue
|
if 'webIsolated' not in target.get('remoteType',''): continue
|
||||||
text='''(() => { const w=[...Services.ww.getWindowEnumerator()].find(w=>w.location.hostname===%s && (!%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))
|
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))
|
||||||
response=r.call(target['consoleActor'],'evaluateJSAsync',text=text)
|
response=r.call(target['consoleActor'],'evaluateJSAsync',text=text)
|
||||||
if 'resultID' not in response: continue
|
if 'resultID' not in response: continue
|
||||||
while True:
|
while True:
|
||||||
|
|
@ -21,32 +20,24 @@ def evaluate(host, script, connection=None):
|
||||||
if value.get('result') is not None and value.get('result') != {'type':'null'}: return value.get('result')
|
if value.get('result') is not None and value.get('result') != {'type':'null'}: return value.get('result')
|
||||||
break
|
break
|
||||||
raise RuntimeError('App not open: '+host)
|
raise RuntimeError('App not open: '+host)
|
||||||
finally:
|
finally:r.s.close()
|
||||||
if connection is None:r.s.close()
|
|
||||||
|
|
||||||
def _run(host,expression,timeout,connection):
|
def run(host,expression,timeout=30):
|
||||||
start=time.monotonic()+timeout
|
start=time.monotonic()+timeout
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
evaluate(host,'window.__androidProbe=null; Promise.resolve().then(async()=>('+expression+')).then(value=>window.__androidProbe=JSON.stringify({value}),error=>window.__androidProbe=JSON.stringify({error:String(error),stack:error.stack})); "started"',connection)
|
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"')
|
||||||
break
|
break
|
||||||
except RuntimeError as e:
|
except (RuntimeError, EOFError, OSError) as e:
|
||||||
if time.monotonic()>=start:raise
|
if time.monotonic()>=start:raise
|
||||||
if not str(e).startswith('App not open:'):raise
|
if isinstance(e, RuntimeError) and not str(e).startswith('App not open:'):raise
|
||||||
time.sleep(.3)
|
time.sleep(.3)
|
||||||
until=time.monotonic()+timeout
|
until=time.monotonic()+timeout
|
||||||
while time.monotonic()<until:
|
while time.monotonic()<until:
|
||||||
result=evaluate(host,'window.__androidProbe || "pending"',connection)
|
result=evaluate(host,'window.__androidProbe || "pending"')
|
||||||
if isinstance(result,str) and result != 'pending':return json.loads(result)
|
if isinstance(result,str) and result != 'pending':return json.loads(result)
|
||||||
time.sleep(.3)
|
time.sleep(.3)
|
||||||
raise TimeoutError(host)
|
raise TimeoutError(host)
|
||||||
|
|
||||||
def run(host,expression,timeout=30):
|
|
||||||
connection=RDP()
|
|
||||||
try:
|
|
||||||
return _run(host,expression,timeout,connection)
|
|
||||||
finally:
|
|
||||||
connection.s.close()
|
|
||||||
|
|
||||||
if __name__=='__main__':
|
if __name__=='__main__':
|
||||||
print(json.dumps(run(sys.argv[1],sys.argv[2]),ensure_ascii=False,indent=2))
|
print(json.dumps(run(sys.argv[1],sys.argv[2]),ensure_ascii=False,indent=2))
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,6 @@ class RDP:
|
||||||
while True:
|
while True:
|
||||||
r = self.recv()
|
r = self.recv()
|
||||||
self.events.append(r)
|
self.events.append(r)
|
||||||
if str(r.get("type", "")).endswith("ListChanged"):
|
|
||||||
continue
|
|
||||||
if r.get("from") == to and (r.get("type") not in ["consoleAPICall", "pageError"]):
|
if r.get("from") == to and (r.get("type") not in ["consoleAPICall", "pageError"]):
|
||||||
return r
|
return r
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,202 +1 @@
|
||||||
// Android-owned data stays in its provider; Gaia keeps its local data separately.
|
// Home layout recovery now lives in the shared host/platform-ui.js.
|
||||||
(() => {
|
|
||||||
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;}
|
self._empty=false;var currDate=new Date(cursor.value.date);batchGroupCounter++;if(!prevDate||(currDate.getTime()==prevDate.getTime())){chunk.push(cursor.value);}else{daysToRender.push(chunk);chunk=[cursor.value];var renderNow=false;if(batchGroupCounter>=MAX_GROUPS_FOR_FIRST_RENDER&&!screenRendered){renderNow=true;screenRendered=true;window.performance.mark('firstChunkReady');}else if(batchGroupCounter>=MAX_GROUPS_TO_BATCH_RENDER){renderNow=true;}
|
||||||
if(renderNow){self.renderSeveralDays(daysToRender);daysToRender=[];batchGroupCounter=0;}}
|
if(renderNow){self.renderSeveralDays(daysToRender);daysToRender=[];batchGroupCounter=0;}}
|
||||||
prevDate=currDate;cursor.continue();},'lastEntryDate',true,true);},renderSeveralDays:function cl_renderSeveralDays(chunks){for(var i=0,l=chunks.length;i<l;i++){this.renderChunk(chunks[i]);}},renderChunk:function cl_renderChunk(chunk){var callLogSection=this.createSectionForGroup(chunk[0]);var phoneNumbers=[];var logGroupContainer=callLogSection.getElementsByTagName('ol')[0];for(var i=0;i<chunk.length;i++){var current=chunk[i];var logGroupDOM=this.createGroup(current);logGroupContainer.appendChild(logGroupDOM);phoneNumbers.push(current.number);}
|
prevDate=currDate;cursor.continue();},'lastEntryDate',true,true);},renderSeveralDays:function cl_renderSeveralDays(chunks){for(var i=0,l=chunks.length;i<l;i++){this.renderChunk(chunks[i]);}},renderChunk:function cl_renderChunk(chunk){var callLogSection=this.createSectionForGroup(chunk[0]);var phoneNumbers=[];var logGroupContainer=callLogSection.getElementsByTagName('ol')[0];for(var i=0;i<chunk.length;i++){var current=chunk[i];var logGroupDOM=this.createGroup(current);logGroupContainer.appendChild(logGroupDOM);phoneNumbers.push(current.number);}
|
||||||
this.callLogContainer.appendChild(callLogSection);if(!this._contactCache){this.updateListWithContactInfo(null,null,logGroupContainer);}},renderEmptyCallLog:function cl_renderEmptyCallLog(isEmptyMissedCallsGroup){this.disableEditModeButton();if(!isEmptyMissedCallsGroup){this._empty=true;}
|
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;}
|
||||||
var noResultContainer=document.getElementById('no-result-container');noResultContainer.hidden=false;var allCallsMsg1=document.getElementById('no-result-msg1');var allCallsMsg2=document.getElementById('no-result-msg2');var noMissedCallsMsg=document.getElementById('no-result-msg3');allCallsMsg1.hidden=isEmptyMissedCallsGroup||false;allCallsMsg2.hidden=isEmptyMissedCallsGroup||false;noMissedCallsMsg.hidden=!isEmptyMissedCallsGroup;if(!isEmptyMissedCallsGroup){var noResultContainerClone=noResultContainer.cloneNode(true);this.callLogContainer.innerHTML='';this.callLogContainer.appendChild(noResultContainerClone);}},appendGroup:function cl_appendGroup(group){if(!this._initialized){return;}
|
var noResultContainer=document.getElementById('no-result-container');noResultContainer.hidden=false;var allCallsMsg1=document.getElementById('no-result-msg1');var allCallsMsg2=document.getElementById('no-result-msg2');var noMissedCallsMsg=document.getElementById('no-result-msg3');allCallsMsg1.hidden=isEmptyMissedCallsGroup||false;allCallsMsg2.hidden=isEmptyMissedCallsGroup||false;noMissedCallsMsg.hidden=!isEmptyMissedCallsGroup;if(!isEmptyMissedCallsGroup){var noResultContainerClone=noResultContainer.cloneNode(true);this.callLogContainer.innerHTML='';this.callLogContainer.appendChild(noResultContainerClone);}},appendGroup:function cl_appendGroup(group){if(!this._initialized){return;}
|
||||||
this.unfilter();this.enableEditModeButton();var logGroupDOM=this.createGroup(group);var dayIndex=group.date;var container=this.callLogContainer;var previousLogGroup=document.getElementById(group.id);if(previousLogGroup){var parent=previousLogGroup.parentNode;parent.removeChild(previousLogGroup);this.insertInSection(logGroupDOM,parent);return logGroupDOM;}
|
this.unfilter();this.enableEditModeButton();var logGroupDOM=this.createGroup(group);var dayIndex=group.date;var container=this.callLogContainer;var previousLogGroup=document.getElementById(group.id);if(previousLogGroup){var parent=previousLogGroup.parentNode;parent.removeChild(previousLogGroup);this.insertInSection(logGroupDOM,parent);return logGroupDOM;}
|
||||||
var groupSelector='[data-timestamp="'+dayIndex+'"]';var sectionExists=container.querySelector(groupSelector);if(sectionExists){var section=sectionExists.getElementsByTagName('ol')[0];this.insertInSection(logGroupDOM,section);return logGroupDOM;}
|
var groupSelector='[data-timestamp="'+dayIndex+'"]';var sectionExists=container.querySelector(groupSelector);if(sectionExists){var section=sectionExists.getElementsByTagName('ol')[0];this.insertInSection(logGroupDOM,section);return logGroupDOM;}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ window.performance.measure('callLogReady','callLogStart');return;}
|
||||||
self._empty=false;var currDate=new Date(cursor.value.date);batchGroupCounter++;if(!prevDate||(currDate.getTime()==prevDate.getTime())){chunk.push(cursor.value);}else{daysToRender.push(chunk);chunk=[cursor.value];var renderNow=false;if(batchGroupCounter>=MAX_GROUPS_FOR_FIRST_RENDER&&!screenRendered){renderNow=true;screenRendered=true;window.performance.mark('firstChunkReady');}else if(batchGroupCounter>=MAX_GROUPS_TO_BATCH_RENDER){renderNow=true;}
|
self._empty=false;var currDate=new Date(cursor.value.date);batchGroupCounter++;if(!prevDate||(currDate.getTime()==prevDate.getTime())){chunk.push(cursor.value);}else{daysToRender.push(chunk);chunk=[cursor.value];var renderNow=false;if(batchGroupCounter>=MAX_GROUPS_FOR_FIRST_RENDER&&!screenRendered){renderNow=true;screenRendered=true;window.performance.mark('firstChunkReady');}else if(batchGroupCounter>=MAX_GROUPS_TO_BATCH_RENDER){renderNow=true;}
|
||||||
if(renderNow){self.renderSeveralDays(daysToRender);daysToRender=[];batchGroupCounter=0;}}
|
if(renderNow){self.renderSeveralDays(daysToRender);daysToRender=[];batchGroupCounter=0;}}
|
||||||
prevDate=currDate;cursor.continue();},'lastEntryDate',true,true);},renderSeveralDays:function cl_renderSeveralDays(chunks){for(var i=0,l=chunks.length;i<l;i++){this.renderChunk(chunks[i]);}},renderChunk:function cl_renderChunk(chunk){var callLogSection=this.createSectionForGroup(chunk[0]);var phoneNumbers=[];var logGroupContainer=callLogSection.getElementsByTagName('ol')[0];for(var i=0;i<chunk.length;i++){var current=chunk[i];var logGroupDOM=this.createGroup(current);logGroupContainer.appendChild(logGroupDOM);phoneNumbers.push(current.number);}
|
prevDate=currDate;cursor.continue();},'lastEntryDate',true,true);},renderSeveralDays:function cl_renderSeveralDays(chunks){for(var i=0,l=chunks.length;i<l;i++){this.renderChunk(chunks[i]);}},renderChunk:function cl_renderChunk(chunk){var callLogSection=this.createSectionForGroup(chunk[0]);var phoneNumbers=[];var logGroupContainer=callLogSection.getElementsByTagName('ol')[0];for(var i=0;i<chunk.length;i++){var current=chunk[i];var logGroupDOM=this.createGroup(current);logGroupContainer.appendChild(logGroupDOM);phoneNumbers.push(current.number);}
|
||||||
this.callLogContainer.appendChild(callLogSection);if(!this._contactCache){this.updateListWithContactInfo(null,null,logGroupContainer);}},renderEmptyCallLog:function cl_renderEmptyCallLog(isEmptyMissedCallsGroup){this.disableEditModeButton();if(!isEmptyMissedCallsGroup){this._empty=true;}
|
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;}
|
||||||
var noResultContainer=document.getElementById('no-result-container');noResultContainer.hidden=false;var allCallsMsg1=document.getElementById('no-result-msg1');var allCallsMsg2=document.getElementById('no-result-msg2');var noMissedCallsMsg=document.getElementById('no-result-msg3');allCallsMsg1.hidden=isEmptyMissedCallsGroup||false;allCallsMsg2.hidden=isEmptyMissedCallsGroup||false;noMissedCallsMsg.hidden=!isEmptyMissedCallsGroup;if(!isEmptyMissedCallsGroup){var noResultContainerClone=noResultContainer.cloneNode(true);this.callLogContainer.innerHTML='';this.callLogContainer.appendChild(noResultContainerClone);}},appendGroup:function cl_appendGroup(group){if(!this._initialized){return;}
|
var noResultContainer=document.getElementById('no-result-container');noResultContainer.hidden=false;var allCallsMsg1=document.getElementById('no-result-msg1');var allCallsMsg2=document.getElementById('no-result-msg2');var noMissedCallsMsg=document.getElementById('no-result-msg3');allCallsMsg1.hidden=isEmptyMissedCallsGroup||false;allCallsMsg2.hidden=isEmptyMissedCallsGroup||false;noMissedCallsMsg.hidden=!isEmptyMissedCallsGroup;if(!isEmptyMissedCallsGroup){var noResultContainerClone=noResultContainer.cloneNode(true);this.callLogContainer.innerHTML='';this.callLogContainer.appendChild(noResultContainerClone);}},appendGroup:function cl_appendGroup(group){if(!this._initialized){return;}
|
||||||
this.unfilter();this.enableEditModeButton();var logGroupDOM=this.createGroup(group);var dayIndex=group.date;var container=this.callLogContainer;var previousLogGroup=document.getElementById(group.id);if(previousLogGroup){var parent=previousLogGroup.parentNode;parent.removeChild(previousLogGroup);this.insertInSection(logGroupDOM,parent);return logGroupDOM;}
|
this.unfilter();this.enableEditModeButton();var logGroupDOM=this.createGroup(group);var dayIndex=group.date;var container=this.callLogContainer;var previousLogGroup=document.getElementById(group.id);if(previousLogGroup){var parent=previousLogGroup.parentNode;parent.removeChild(previousLogGroup);this.insertInSection(logGroupDOM,parent);return logGroupDOM;}
|
||||||
var groupSelector='[data-timestamp="'+dayIndex+'"]';var sectionExists=container.querySelector(groupSelector);if(sectionExists){var section=sectionExists.getElementsByTagName('ol')[0];this.insertInSection(logGroupDOM,section);return logGroupDOM;}
|
var groupSelector='[data-timestamp="'+dayIndex+'"]';var sectionExists=container.querySelector(groupSelector);if(sectionExists){var section=sectionExists.getElementsByTagName('ol')[0];this.insertInSection(logGroupDOM,section);return logGroupDOM;}
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,6 @@
|
||||||
function labels(){document.getElementById('browse').textContent=text('Ouvrir le catalogue','Open catalogue');document.getElementById('open').textContent=text('Ouvrir','Open');document.getElementById('install').textContent=text('Installer','Install');document.getElementById('cancel').textContent=text('Annuler','Cancel');}
|
function labels(){document.getElementById('browse').textContent=text('Ouvrir le catalogue','Open catalogue');document.getElementById('open').textContent=text('Ouvrir','Open');document.getElementById('install').textContent=text('Installer','Install');document.getElementById('cancel').textContent=text('Annuler','Cancel');}
|
||||||
function loadFailed(){status(text('Catalogue inaccessible. Vérifiez la connexion Internet, puis réessayez.','Catalogue unavailable. Check your Internet connection, then try again.'));document.getElementById('open').hidden=true;}
|
function loadFailed(){status(text('Catalogue inaccessible. Vérifiez la connexion Internet, puis réessayez.','Catalogue unavailable. Check your Internet connection, then try again.'));document.getElementById('open').hidden=true;}
|
||||||
frame.addEventListener('mozbrowsererror',loadFailed);
|
frame.addEventListener('mozbrowsererror',loadFailed);
|
||||||
frame.addEventListener('mozbrowserclose',()=>{
|
|
||||||
if(busy)return;
|
|
||||||
status(text('Le catalogue est fermé. Vous pouvez le rouvrir ci-dessous.',
|
|
||||||
'The catalogue is closed. You can reopen it below.'));
|
|
||||||
document.getElementById('open').hidden=true;
|
|
||||||
});
|
|
||||||
function browse(){labels();message.hidden=true;dialog.hidden=true;frame.style.visibility='visible';frame.src='https://vulpes-os.org/marketplace/?mode=app&lang='+(fr()?'fr':'en');}
|
function browse(){labels();message.hidden=true;dialog.hidden=true;frame.style.visibility='visible';frame.src='https://vulpes-os.org/marketplace/?mode=app&lang='+(fr()?'fr':'en');}
|
||||||
function status(value){frame.style.visibility='hidden';message.hidden=false;document.getElementById('status').textContent=value;}
|
function status(value){frame.style.visibility='hidden';message.hidden=false;document.getElementById('status').textContent=value;}
|
||||||
async function prepare(url){
|
async function prepare(url){
|
||||||
|
|
|
||||||
279
changelog.json
279
changelog.json
File diff suppressed because one or more lines are too long
|
|
@ -265,7 +265,8 @@ var CallLog = {
|
||||||
// If the contacts cache is not valid, we retrieve the contacts information
|
// If the contacts cache is not valid, we retrieve the contacts information
|
||||||
// directly from the contacts API and update the DOM accordingly.
|
// directly from the contacts API and update the DOM accordingly.
|
||||||
if (!this._contactCache) {
|
if (!this._contactCache) {
|
||||||
this.updateListWithContactInfo(null, null, logGroupContainer);
|
this.updateListWithContactInfo(null, null, phoneNumbers,
|
||||||
|
logGroupContainer);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,10 @@ var LayoutDictionaryDownloader = function(imEngineId, dictFilename) {
|
||||||
this._xhr = null;
|
this._xhr = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Dictionaries ship with Vulpes so adding a layout also works offline.
|
// Remote URLs to download the data. We require the remote server to have
|
||||||
|
// CORS turned on since we are not asking for SystemXHR permission.
|
||||||
LayoutDictionaryDownloader.prototype.REMOTE_URL =
|
LayoutDictionaryDownloader.prototype.REMOTE_URL =
|
||||||
'/js/imes/%imEngineId/dictionaries/%dictFilename';
|
'https://fxos.cdn.mozilla.net/dictionaries/%imEngineId/%ver/%dictFilename';
|
||||||
|
|
||||||
// If the data schema changes in an incompatible way, we must bump the
|
// If the data schema changes in an incompatible way, we must bump the
|
||||||
// version-to-download here.
|
// version-to-download here.
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,6 @@
|
||||||
function labels(){document.getElementById('browse').textContent=text('Ouvrir le catalogue','Open catalogue');document.getElementById('open').textContent=text('Ouvrir','Open');document.getElementById('install').textContent=text('Installer','Install');document.getElementById('cancel').textContent=text('Annuler','Cancel');}
|
function labels(){document.getElementById('browse').textContent=text('Ouvrir le catalogue','Open catalogue');document.getElementById('open').textContent=text('Ouvrir','Open');document.getElementById('install').textContent=text('Installer','Install');document.getElementById('cancel').textContent=text('Annuler','Cancel');}
|
||||||
function loadFailed(){status(text('Catalogue inaccessible. Vérifiez la connexion Internet, puis réessayez.','Catalogue unavailable. Check your Internet connection, then try again.'));document.getElementById('open').hidden=true;}
|
function loadFailed(){status(text('Catalogue inaccessible. Vérifiez la connexion Internet, puis réessayez.','Catalogue unavailable. Check your Internet connection, then try again.'));document.getElementById('open').hidden=true;}
|
||||||
frame.addEventListener('mozbrowsererror',loadFailed);
|
frame.addEventListener('mozbrowsererror',loadFailed);
|
||||||
frame.addEventListener('mozbrowserclose',()=>{
|
|
||||||
if(busy)return;
|
|
||||||
status(text('Le catalogue est fermé. Vous pouvez le rouvrir ci-dessous.',
|
|
||||||
'The catalogue is closed. You can reopen it below.'));
|
|
||||||
document.getElementById('open').hidden=true;
|
|
||||||
});
|
|
||||||
function browse(){labels();message.hidden=true;dialog.hidden=true;frame.style.visibility='visible';frame.src='https://vulpes-os.org/marketplace/?mode=app&lang='+(fr()?'fr':'en');}
|
function browse(){labels();message.hidden=true;dialog.hidden=true;frame.style.visibility='visible';frame.src='https://vulpes-os.org/marketplace/?mode=app&lang='+(fr()?'fr':'en');}
|
||||||
function status(value){frame.style.visibility='hidden';message.hidden=false;document.getElementById('status').textContent=value;}
|
function status(value){frame.style.visibility='hidden';message.hidden=false;document.getElementById('status').textContent=value;}
|
||||||
async function prepare(url){
|
async function prepare(url){
|
||||||
|
|
|
||||||
|
|
@ -86,16 +86,7 @@ define(function(require) {
|
||||||
var path = pathElement ? pathElement.dataset.path : null;
|
var path = pathElement ? pathElement.dataset.path : null;
|
||||||
|
|
||||||
var panelFuncLoaded = function(panelFunc) {
|
var panelFuncLoaded = function(panelFunc) {
|
||||||
var panel;
|
var panel = panelFunc();
|
||||||
try {
|
|
||||||
panel = panelFunc();
|
|
||||||
if (!panel || typeof panel.beforeShow !== 'function') {
|
|
||||||
throw new TypeError('Invalid settings panel: ' + panelId);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (callback) callback(null, error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_panelCache[panelId] = panel;
|
_panelCache[panelId] = panel;
|
||||||
if (callback) {
|
if (callback) {
|
||||||
callback(panel);
|
callback(panel);
|
||||||
|
|
@ -106,8 +97,6 @@ define(function(require) {
|
||||||
require([path], function(panelFunc) {
|
require([path], function(panelFunc) {
|
||||||
// Create a new panel object for static panels.
|
// Create a new panel object for static panels.
|
||||||
panelFuncLoaded(panelFunc ? panelFunc : SettingsPanel);
|
panelFuncLoaded(panelFunc ? panelFunc : SettingsPanel);
|
||||||
}, function(error) {
|
|
||||||
if (callback) callback(null, error);
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
panelFuncLoaded(SettingsPanel);
|
panelFuncLoaded(SettingsPanel);
|
||||||
|
|
|
||||||
|
|
@ -187,12 +187,7 @@ define(function(require) {
|
||||||
}
|
}
|
||||||
|
|
||||||
_loadPanel(panelId, function() {
|
_loadPanel(panelId, function() {
|
||||||
PanelCache.get(panelId, function(panel, loadError) {
|
PanelCache.get(panelId, function(panel) {
|
||||||
if (loadError || !panel) {
|
|
||||||
console.error('Settings panel ' + panelId + ':', loadError);
|
|
||||||
callback(loadError || new Error('Missing settings panel: ' + panelId));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var newPanelElement = document.getElementById(panelId);
|
var newPanelElement = document.getElementById(panelId);
|
||||||
var currentPanelId =
|
var currentPanelId =
|
||||||
_currentNavigation && _currentNavigation.panelId;
|
_currentNavigation && _currentNavigation.panelId;
|
||||||
|
|
|
||||||
|
|
@ -408,13 +408,14 @@
|
||||||
|
|
||||||
var appStarter = AppStarter();
|
var appStarter = AppStarter();
|
||||||
|
|
||||||
// Deferred Gaia components contain UMD wrappers. Finish loading them before
|
if (document.readyState !== 'loading') {
|
||||||
// Alameda starts, otherwise an anonymous component can become a panel module.
|
|
||||||
if (document.readyState === 'complete') {
|
|
||||||
appStarter.start();
|
appStarter.start();
|
||||||
} else {
|
} else {
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('readystatechange', function readyStateChange() {
|
||||||
appStarter.start();
|
if (document.readyState == 'interactive') {
|
||||||
}, { once: true });
|
document.removeEventListener('readystatechange', readyStateChange);
|
||||||
|
appStarter.start();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -55,15 +55,8 @@
|
||||||
ime.dispatchEvent(new Event('inputcontextchange'));
|
ime.dispatchEvent(new Event('inputcontextchange'));
|
||||||
};
|
};
|
||||||
Object.defineProperty(navigator,'mozInputMethod',{value:ime});
|
Object.defineProperty(navigator,'mozInputMethod',{value:ime});
|
||||||
let focusReceived=false;
|
addEventListener('vulpes-service-event',({detail})=>{if(detail.type==='input-focus')setState(detail.data);});
|
||||||
addEventListener('vulpes-service-event',({detail})=>{
|
call('input.request',{operation:'state'}).then(setState).catch(console.error);
|
||||||
if(detail.type==='input-focus') {focusReceived=true;setState(detail.data);}
|
|
||||||
});
|
|
||||||
// A focus event can arrive while the initial state request is in flight.
|
|
||||||
// Its older snapshot must not clear or reopen the newer input context.
|
|
||||||
call('input.request',{operation:'state'}).then(state=>{
|
|
||||||
if(!focusReceived)setState(state);
|
|
||||||
}).catch(console.error);
|
|
||||||
window.resizeTo=(_width,height)=>call('input.request',{operation:'height',height}).catch(console.error);
|
window.resizeTo=(_width,height)=>call('input.request',{operation:'height',height}).catch(console.error);
|
||||||
}
|
}
|
||||||
if(app==='system') {
|
if(app==='system') {
|
||||||
|
|
|
||||||
|
|
@ -103,25 +103,3 @@
|
||||||
background-color: var(--vulpes-surface) !important;
|
background-color: var(--vulpes-surface) !important;
|
||||||
color: var(--vulpes-text) !important;
|
color: var(--vulpes-text) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keep the message and its surface paired, including lazily loaded Gaia dialogs. */
|
|
||||||
:root #vulpes-gaia-notice,
|
|
||||||
:root #vulpes-gaia-notice > section,
|
|
||||||
:root #vulpes-gaia-notice > section > p {
|
|
||||||
background-color: #f4f4f4 !important;
|
|
||||||
color: #202b33 !important;
|
|
||||||
}
|
|
||||||
:root[data-vulpes-theme="dark"] #vulpes-gaia-notice,
|
|
||||||
:root[data-vulpes-theme="dark"] #vulpes-gaia-notice > section,
|
|
||||||
:root[data-vulpes-theme="dark"] #vulpes-gaia-notice > section > p {
|
|
||||||
background-color: var(--vulpes-surface) !important;
|
|
||||||
color: var(--vulpes-text) !important;
|
|
||||||
}
|
|
||||||
:root[data-vulpes-app="settings"] p[data-vulpes-unavailable] {
|
|
||||||
background-color: #f4f4f4 !important;
|
|
||||||
color: #202b33 !important;
|
|
||||||
}
|
|
||||||
:root[data-vulpes-theme="dark"][data-vulpes-app="settings"] p[data-vulpes-unavailable] {
|
|
||||||
background-color: var(--vulpes-surface) !important;
|
|
||||||
color: var(--vulpes-text) !important;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -223,124 +223,10 @@ break;}
|
||||||
var cset={};cset[key]=value;_settings.createLock().set(cset);}};});define('modules/settings_panel',['require','modules/panel','modules/settings_cache','modules/panel_utils'],function(require){'use strict';var Panel=require('modules/panel');var SettingsCache=require('modules/settings_cache');var PanelUtils=require('modules/panel_utils');var _emptyFunc=function panel_emptyFunc(){};var SettingsPanel=function ctor_SettingsPanel(options){var _panel=null;var _settingsChangeHandler=function(event){PanelUtils.onSettingsChange(_panel,event);};var _addListeners=function panel_addListeners(panel){if(!panel){return;}
|
var cset={};cset[key]=value;_settings.createLock().set(cset);}};});define('modules/settings_panel',['require','modules/panel','modules/settings_cache','modules/panel_utils'],function(require){'use strict';var Panel=require('modules/panel');var SettingsCache=require('modules/settings_cache');var PanelUtils=require('modules/panel_utils');var _emptyFunc=function panel_emptyFunc(){};var SettingsPanel=function ctor_SettingsPanel(options){var _panel=null;var _settingsChangeHandler=function(event){PanelUtils.onSettingsChange(_panel,event);};var _addListeners=function panel_addListeners(panel){if(!panel){return;}
|
||||||
SettingsCache.addEventListener('settingsChange',_settingsChangeHandler);panel.addEventListener('change',PanelUtils.onInputChange);panel.addEventListener('click',PanelUtils.onLinkClick);};var _removeListeners=function panel_removeListeners(panel){if(!panel){return;}
|
SettingsCache.addEventListener('settingsChange',_settingsChangeHandler);panel.addEventListener('change',PanelUtils.onInputChange);panel.addEventListener('click',PanelUtils.onLinkClick);};var _removeListeners=function panel_removeListeners(panel){if(!panel){return;}
|
||||||
SettingsCache.removeEventListener('settingsChange',_settingsChangeHandler);panel.removeEventListener('change',PanelUtils.onInputChange);panel.removeEventListener('click',PanelUtils.onLinkClick);};options=options||{};options.onInit=options.onInit||_emptyFunc;options.onUninit=options.onUninit||_emptyFunc;options.onShow=options.onShow||_emptyFunc;options.onHide=options.onHide||_emptyFunc;options.onBeforeShow=options.onBeforeShow||_emptyFunc;options.onBeforeHide=options.onBeforeHide||_emptyFunc;return Panel({onInit:function(panel,initOptions){if(!panel){return;}
|
SettingsCache.removeEventListener('settingsChange',_settingsChangeHandler);panel.removeEventListener('change',PanelUtils.onInputChange);panel.removeEventListener('click',PanelUtils.onLinkClick);};options=options||{};options.onInit=options.onInit||_emptyFunc;options.onUninit=options.onUninit||_emptyFunc;options.onShow=options.onShow||_emptyFunc;options.onHide=options.onHide||_emptyFunc;options.onBeforeShow=options.onBeforeShow||_emptyFunc;options.onBeforeHide=options.onBeforeHide||_emptyFunc;return Panel({onInit:function(panel,initOptions){if(!panel){return;}
|
||||||
_panel=panel;PanelUtils.activate(panel);return options.onInit(panel,initOptions);},onUninit:function(){_removeListeners(_panel);_panel=null;options.onUninit();},onShow:function(panel,showOptions){return options.onShow(panel,showOptions);},onHide:function(){_removeListeners(_panel);return options.onHide();},onBeforeShow:function(panel,beforeShowOptions){PanelUtils.preset(panel);_addListeners(panel);return options.onBeforeShow(panel,beforeShowOptions);},onBeforeHide:function(){return options.onBeforeHide();}});};return SettingsPanel;});define('modules/panel_cache',["require", "modules/settings_panel", "shared/lazy_loader"],function(require) {
|
_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;}
|
||||||
'use strict';
|
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 SettingsPanel = require('modules/settings_panel');
|
var cachedPanel=_panelCache[panelId];if(cachedPanel){if(callback){callback(cachedPanel);}}else{var panelElement=document.getElementById(panelId);if(panelElement){var pathElement=panelElement.querySelector('panel');var path=pathElement?pathElement.dataset.path:null;var panelFuncLoaded=function(panelFunc){var panel=panelFunc();_panelCache[panelId]=panel;if(callback){callback(panel);}};if(path){require([path],function(panelFunc){panelFuncLoaded(panelFunc?panelFunc:SettingsPanel);});}else{panelFuncLoaded(SettingsPanel);}}else{if(callback){callback(null);}}}}};});var ScreenLayout={defaultQueries:{tiny:'(max-width: 767px)',small:'(min-width: 768px) and (max-width: 991px)',medium:'(min-width: 992px) and (max-width: 1200px)',large:'(min-width: 1201px)',hardwareHomeButton:window.VulpesCompat?'(min-width: 0px)':'(-moz-physical-home-button)'},init:function sl_init(){this.queries=(function(qs){var result={};for(var key in qs){result[key]=window.matchMedia(qs[key]);}
|
||||||
var LazyLoader = require('shared/lazy_loader');
|
|
||||||
|
|
||||||
var _panelCache = {};
|
|
||||||
var _panelStylesheetsLoaded = false;
|
|
||||||
|
|
||||||
// experiment result shows
|
|
||||||
// load all related styles once is time saver
|
|
||||||
var _loadPanelStylesheetsIfNeeded = function loadPanelCSS() {
|
|
||||||
if (_panelStylesheetsLoaded) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
LazyLoader.load(['shared/style/action_menu.css',
|
|
||||||
'shared/style/confirm.css',
|
|
||||||
'shared/style/progress_activity.css',
|
|
||||||
'shared/elements/gaia-icons/bidi-helper.css',
|
|
||||||
'shared/elements/gaia_buttons/script.js',
|
|
||||||
'shared/elements/gaia_confirm/script.js',
|
|
||||||
'style/homescreens.css',
|
|
||||||
// Bug 1210284 - disabling achievements until they are on
|
|
||||||
// the roadmap again or completely removed.
|
|
||||||
// 'style/achievements.css',
|
|
||||||
'style/apps.css',
|
|
||||||
'style/screen_lock.css',
|
|
||||||
'style/simcard.css',
|
|
||||||
'style/updates.css',
|
|
||||||
'style/downloads.css',
|
|
||||||
'style/developer_service_workers.css'],
|
|
||||||
function callback() {
|
|
||||||
_panelStylesheetsLoaded = true;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// load styles in idle time after document loaded
|
|
||||||
navigator.addIdleObserver({
|
|
||||||
time: 3,
|
|
||||||
onidle: _loadPanelStylesheetsIfNeeded
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
// this is for unit test
|
|
||||||
reset: function spc_reset() {
|
|
||||||
_panelCache = {};
|
|
||||||
_panelStylesheetsLoaded = false;
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the panel module of a specified id. If there is no corresponding
|
|
||||||
* panel module of the id, it returns SettingsPanel.
|
|
||||||
*
|
|
||||||
* @alias module:PanelCache#get
|
|
||||||
* @param {String} panelId
|
|
||||||
* The id of the to be loaded panel.
|
|
||||||
* @param {Function} callback
|
|
||||||
* The function to be called when the panel is loaded.
|
|
||||||
*/
|
|
||||||
get: function spc_get(panelId, callback) {
|
|
||||||
if (!panelId && !callback) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (panelId !== 'root') {
|
|
||||||
_loadPanelStylesheetsIfNeeded();
|
|
||||||
}
|
|
||||||
|
|
||||||
var cachedPanel = _panelCache[panelId];
|
|
||||||
if (cachedPanel) {
|
|
||||||
if (callback) {
|
|
||||||
callback(cachedPanel);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Get the path of the panel creation function
|
|
||||||
var panelElement = document.getElementById(panelId);
|
|
||||||
if (panelElement) {
|
|
||||||
var pathElement = panelElement.querySelector('panel');
|
|
||||||
var path = pathElement ? pathElement.dataset.path : null;
|
|
||||||
|
|
||||||
var panelFuncLoaded = function(panelFunc) {
|
|
||||||
var panel;
|
|
||||||
try {
|
|
||||||
panel = panelFunc();
|
|
||||||
if (!panel || typeof panel.beforeShow !== 'function') {
|
|
||||||
throw new TypeError('Invalid settings panel: ' + panelId);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (callback) callback(null, error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_panelCache[panelId] = panel;
|
|
||||||
if (callback) {
|
|
||||||
callback(panel);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (path) {
|
|
||||||
require([path], function(panelFunc) {
|
|
||||||
// Create a new panel object for static panels.
|
|
||||||
panelFuncLoaded(panelFunc ? panelFunc : SettingsPanel);
|
|
||||||
}, function(error) {
|
|
||||||
if (callback) callback(null, error);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
panelFuncLoaded(SettingsPanel);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (callback) {
|
|
||||||
callback(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
var ScreenLayout={defaultQueries:{tiny:'(max-width: 767px)',small:'(min-width: 768px) and (max-width: 991px)',medium:'(min-width: 992px) and (max-width: 1200px)',large:'(min-width: 1201px)',hardwareHomeButton:window.VulpesCompat?'(min-width: 0px)':'(-moz-physical-home-button)'},init:function sl_init(){this.queries=(function(qs){var result={};for(var key in qs){result[key]=window.matchMedia(qs[key]);}
|
|
||||||
return result;})(this.defaultQueries);},_isOnRealDevice:undefined,isOnRealDevice:function sl_isOnRealDevice(){if(typeof(this._isOnRealDevice)!=='undefined'){return this._isOnRealDevice;}
|
return result;})(this.defaultQueries);},_isOnRealDevice:undefined,isOnRealDevice:function sl_isOnRealDevice(){if(typeof(this._isOnRealDevice)!=='undefined'){return this._isOnRealDevice;}
|
||||||
if(window.innerWidth===screen.availWidth){this._isOnRealDevice=true;}else{this._isOnRealDevice=false;}
|
if(window.innerWidth===screen.availWidth){this._isOnRealDevice=true;}else{this._isOnRealDevice=false;}
|
||||||
return this._isOnRealDevice;},getCurrentLayout:function sl_getCurrentLayout(type){if(type===undefined){for(var name in this.defaultQueries){if(this.queries[name]&&this.queries[name].matches){return name;}}}
|
return this._isOnRealDevice;},getCurrentLayout:function sl_getCurrentLayout(type){if(type===undefined){for(var name in this.defaultQueries){if(this.queries[name]&&this.queries[name].matches){return name;}}}
|
||||||
|
|
@ -557,12 +443,7 @@ define('modules/settings_service',["require", "modules/page_transitions", "modul
|
||||||
}
|
}
|
||||||
|
|
||||||
_loadPanel(panelId, function() {
|
_loadPanel(panelId, function() {
|
||||||
PanelCache.get(panelId, function(panel, loadError) {
|
PanelCache.get(panelId, function(panel) {
|
||||||
if (loadError || !panel) {
|
|
||||||
console.error('Settings panel ' + panelId + ':', loadError);
|
|
||||||
callback(loadError || new Error('Missing settings panel: ' + panelId));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var newPanelElement = document.getElementById(panelId);
|
var newPanelElement = document.getElementById(panelId);
|
||||||
var currentPanelId =
|
var currentPanelId =
|
||||||
_currentNavigation && _currentNavigation.panelId;
|
_currentNavigation && _currentNavigation.panelId;
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
{
|
{
|
||||||
"gaia": "2.7.0",
|
"gaia": "2.7.0",
|
||||||
"channel": "preview",
|
"channel": "preview",
|
||||||
"revision": 21,
|
"revision": 20,
|
||||||
"androidVersionCode": 21,
|
"androidVersionCode": 20,
|
||||||
"geckoview": "156.0.20260921121718",
|
"geckoview": "156.0.20260921121718",
|
||||||
"engineAlignment": {
|
"engineAlignment": {
|
||||||
"family": "156",
|
"family": "156",
|
||||||
"patchParity": false,
|
"patchParity": false,
|
||||||
"reason": "Firefox desktop 156.0.1; Mozilla's GeckoView release repository currently offers 156.0 only. Keep the desktop patch and report actual engine versions."
|
"reason": "Firefox desktop 156.0.1; Mozilla's GeckoView release repository currently offers 156.0 only. Keep the desktop patch and report actual engine versions."
|
||||||
},
|
},
|
||||||
"version": "2.7-preview.21",
|
"version": "2.7-preview.20",
|
||||||
"adapters": {
|
"adapters": {
|
||||||
"desktop": "0.1.0-dev",
|
"desktop": "0.1.0-dev",
|
||||||
"android": "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 vm from 'node:vm';
|
||||||
import {readFileSync} from 'node:fs';
|
import {readFileSync} from 'node:fs';
|
||||||
const source=readFileSync(new URL('../android/extension/content.js',import.meta.url),'utf8');
|
const source=readFileSync(new URL('../android/extension/content.js',import.meta.url),'utf8');
|
||||||
async function setup(enabled=true,{defer=false,activeElement}={}) {
|
async function setup(enabled=true) {
|
||||||
const sent=[],events=new Map(),timers=[];let receive,disconnect;
|
const sent=[],events=new Map();let receive;
|
||||||
const page={navigator:{},dispatchEvent(){}};
|
const page={navigator:{},dispatchEvent(){}};
|
||||||
const port={postMessage:m=>sent.push(m),onMessage:{addListener:fn=>receive=fn},onDisconnect:{addListener:fn=>disconnect=fn}};
|
const port={postMessage:m=>sent.push(m),onMessage:{addListener:fn=>receive=fn},onDisconnect:{addListener(){}}};
|
||||||
class Editor {
|
class Editor {
|
||||||
constructor(){this.editing=false;}
|
constructor(){this.editing=false;}
|
||||||
focus(e){this.inputElement=e;}
|
focus(e){this.inputElement=e;}
|
||||||
|
|
@ -14,14 +14,14 @@ async function setup(enabled=true,{defer=false,activeElement}={}) {
|
||||||
edit(){return this.state();}
|
edit(){return this.state();}
|
||||||
}
|
}
|
||||||
const context={InputEditor:Editor,browser:{runtime:{connect:()=>port}},console,
|
const context={InputEditor:Editor,browser:{runtime:{connect:()=>port}},console,
|
||||||
location:{port:'18765',hostname:'sms.localhost'},document:{hidden:false,activeElement,hasFocus:()=>true},
|
location:{port:'18765',hostname:'sms.localhost'},document:{hidden:false},
|
||||||
window:{wrappedJSObject:page,Promise,Error,Event,CustomEvent:class extends Event{constructor(name,p){super(name);this.detail=p.detail;}}},
|
window:{wrappedJSObject:page,Promise,Error,Event,CustomEvent:class extends Event{constructor(name,p){super(name);this.detail=p.detail;}}},
|
||||||
cloneInto:x=>x,exportFunction:(fn,obj,p)=>{obj[p.defineAs]=fn;},
|
cloneInto:x=>x,exportFunction:(fn,obj,p)=>{obj[p.defineAs]=fn;},
|
||||||
addEventListener:(name,fn)=>events.set(name,fn),setTimeout:fn=>timers.push(fn),clearTimeout(){}};
|
addEventListener:(name,fn)=>events.set(name,fn),setTimeout,clearTimeout};
|
||||||
vm.runInNewContext(source,context);
|
vm.runInNewContext(source,context);
|
||||||
if(!defer) receive({id:sent[0].id,reply:{result:enabled}});
|
receive({id:sent[0].id,reply:{result:enabled}});
|
||||||
await new Promise(resolve=>setImmediate(resolve));
|
await new Promise(resolve=>setImmediate(resolve));
|
||||||
return {sent,events,page,receive,context,timers,disconnect};
|
return {sent,events,page,receive,context};
|
||||||
}
|
}
|
||||||
function field(mode='numeric') {
|
function field(mode='numeric') {
|
||||||
const attrs=new Map([['inputmode',mode]]);
|
const attrs=new Map([['inputmode',mode]]);
|
||||||
|
|
@ -51,54 +51,3 @@ test('Android keyboard remains the default and hidden Gaia restores the editor m
|
||||||
assert.equal(e.getAttribute('inputmode'),'numeric');
|
assert.equal(e.getAttribute('inputmode'),'numeric');
|
||||||
assert.equal(s.sent.at(-1).inputFocus,null);
|
assert.equal(s.sent.at(-1).inputFocus,null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a field focused before the preference arrives activates Gaia without another tap',async()=>{
|
|
||||||
const e=field(),s=await setup(true,{defer:true,activeElement:e});
|
|
||||||
s.events.get('focusin')({target:e});
|
|
||||||
assert.equal(s.sent.length,1);
|
|
||||||
s.receive({id:s.sent[0].id,reply:{result:true}});
|
|
||||||
await new Promise(resolve=>setImmediate(resolve));
|
|
||||||
assert.equal(e.getAttribute('inputmode'),'none');
|
|
||||||
assert.equal(s.sent.at(-1).inputFocus.inputMode,'numeric');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('an old preference reply cannot undo a newer keyboard toggle',async()=>{
|
|
||||||
const e=field(),s=await setup(false,{defer:true,activeElement:e});
|
|
||||||
s.receive({event:{type:'input-mode',data:{enabled:true}}});
|
|
||||||
s.receive({id:s.sent[0].id,reply:{result:false}});
|
|
||||||
await new Promise(resolve=>setImmediate(resolve));
|
|
||||||
assert.equal(s.page.VulpesNativeInput,true);
|
|
||||||
assert.equal(e.getAttribute('inputmode'),'none');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returning to an app restores its focused Gaia editor',async()=>{
|
|
||||||
const e=field(),s=await setup(true,{activeElement:e});
|
|
||||||
s.context.document.hidden=true;s.events.get('visibilitychange')();
|
|
||||||
assert.equal(s.sent.at(-1).inputFocus,null);
|
|
||||||
s.context.document.hidden=false;s.events.get('visibilitychange')();
|
|
||||||
assert.equal(s.sent.at(-1).inputFocus.inputMode,'numeric');
|
|
||||||
assert.equal(e.getAttribute('inputmode'),'none');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('bridge reconnection rereads the preference and restores the active editor',async()=>{
|
|
||||||
const e=field(),s=await setup(true,{activeElement:e});
|
|
||||||
s.disconnect();
|
|
||||||
assert.equal(e.getAttribute('inputmode'),'numeric');
|
|
||||||
assert.equal(s.page.VulpesNativeInput,false);
|
|
||||||
s.timers.at(-1)();
|
|
||||||
const request=s.sent.at(-1);
|
|
||||||
assert.equal(request.request.method,'input.config');
|
|
||||||
s.receive({id:request.id,reply:{result:true}});
|
|
||||||
await new Promise(resolve=>setImmediate(resolve));
|
|
||||||
assert.equal(e.getAttribute('inputmode'),'none');
|
|
||||||
assert.equal(s.sent.at(-1).inputFocus.inputMode,'numeric');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('a background app cannot reclaim the keyboard when Android resumes',async()=>{
|
|
||||||
const e=field(),s=await setup(true,{activeElement:e});
|
|
||||||
s.context.document.hidden=true;s.events.get('visibilitychange')();
|
|
||||||
s.context.document.hidden=false;s.context.document.hasFocus=()=>false;
|
|
||||||
s.events.get('visibilitychange')();
|
|
||||||
assert.equal(s.sent.at(-1).inputFocus,null);
|
|
||||||
assert.equal(e.getAttribute('inputmode'),'numeric');
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
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/);
|
|
||||||
});
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
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')();}
|
|
||||||
});
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
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');
|
|
||||||
});
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
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');});
|
|
||||||
});
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
import copy
|
import copy
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
import re
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
|
@ -42,8 +41,8 @@ class ChangelogTests(unittest.TestCase):
|
||||||
self.assertTrue(documents['release-body.md'].startswith(self.source['releases'][self.tag]['summary']))
|
self.assertTrue(documents['release-body.md'].startswith(self.source['releases'][self.tag]['summary']))
|
||||||
for lang in export.LANGUAGES:
|
for lang in export.LANGUAGES:
|
||||||
text = documents[f'CHANGELOG.{lang}.md']
|
text = documents[f'CHANGELOG.{lang}.md']
|
||||||
self.assertIn('## 30/09/2026 : 2.7-preview.14', text)
|
self.assertIn('## 2.7-preview.14', text)
|
||||||
self.assertNotIn('## 2.7-156.0.1', text)
|
self.assertNotIn('## 2.7-156.0.1 ', text)
|
||||||
future = copy.deepcopy(self.source['entries'][0])
|
future = copy.deepcopy(self.source['entries'][0])
|
||||||
future['id'] = 'future'
|
future['id'] = 'future'
|
||||||
self.source['entries'].insert(0, future)
|
self.source['entries'].insert(0, future)
|
||||||
|
|
@ -63,47 +62,16 @@ class ChangelogTests(unittest.TestCase):
|
||||||
export.generate(p, Path(d), Path(d)/'site.json', Path(d)/'releases')
|
export.generate(p, Path(d), Path(d)/'site.json', Path(d)/'releases')
|
||||||
self.assertFalse((Path(d)/'site.json').exists())
|
self.assertFalse((Path(d)/'site.json').exists())
|
||||||
|
|
||||||
def test_grouped_entries_keep_explicit_platforms_in_both_languages(self):
|
def test_flat_entry_keeps_explicit_platforms_in_both_languages(self):
|
||||||
for language in export.LANGUAGES:
|
for language in export.LANGUAGES:
|
||||||
for source, rendered in zip(self.source['entries'], self.languages[language]):
|
for source, rendered in zip(self.source['entries'], self.languages[language]):
|
||||||
self.assertEqual(rendered['platforms'], source['metadata']['platforms'])
|
self.assertEqual(rendered['platforms'], source['metadata']['platforms'])
|
||||||
self.assertEqual(rendered['models'], source['metadata']['models'])
|
self.assertEqual(rendered['models'], source['metadata']['models'])
|
||||||
self.assertEqual(rendered['status'], source['metadata']['status'])
|
self.assertEqual(rendered['status'], source['metadata']['status'])
|
||||||
self.assertEqual({p for group in rendered['groups'] for p in group['platforms']},
|
self.assertEqual(rendered['groups'][0]['platforms'], rendered['platforms'])
|
||||||
set(rendered['platforms']))
|
|
||||||
|
|
||||||
def test_date_first_heading_and_platform_badges(self):
|
|
||||||
for language in export.LANGUAGES:
|
|
||||||
entry = next(e for e in self.languages[language] if e['id'] == '2.7-preview.20')
|
|
||||||
self.assertEqual(entry['id'], '2.7-preview.20')
|
|
||||||
self.assertEqual(entry['date'], '2026-10-13')
|
|
||||||
common = next(g for g in entry['groups'] if g['common'])
|
|
||||||
self.assertEqual(common['platforms'], ['pc', 'apk', 'models'])
|
|
||||||
web = next(g for g in entry['groups'] if g['name'] == 'Web')
|
|
||||||
self.assertEqual(web['platforms'], ['web'])
|
|
||||||
self.assertIn('>Web</span>', web['nameHtml'])
|
|
||||||
self.assertEqual(entry['notes'], '')
|
|
||||||
self.assertEqual(export.group_scope('')[1], ['pc', 'models'])
|
|
||||||
with self.assertRaisesRegex(ValueError, 'Unknown platform'):
|
|
||||||
export.group_scope('Unknown')
|
|
||||||
|
|
||||||
def test_tags_alphabetical_in_every_platform_group(self):
|
|
||||||
for entry in self.source['entries']:
|
|
||||||
for lang in export.LANGUAGES:
|
|
||||||
for group in re.split(r'^### ', entry['text'][lang], flags=re.M)[1:]:
|
|
||||||
tags = re.findall(r'^- !\[([^\]]+)\]', group, re.M)
|
|
||||||
self.assertTrue(tags, (entry['id'], lang))
|
|
||||||
self.assertEqual(tags, sorted(tags), (entry['id'], lang))
|
|
||||||
self.assertLessEqual(set(tags), {'Add', 'Change', 'Fix', 'Note', 'Test'})
|
|
||||||
|
|
||||||
def test_release_summary_follows_the_entry_without_copying_text(self):
|
|
||||||
tag = 'v2.7-preview.20'
|
|
||||||
next(e for e in self.source['entries'] if e['id'] == '2.7-preview.20')['text']['fr'] += '\nA new correction.\n'
|
|
||||||
docs = export.release_documents(self.source, tag)
|
|
||||||
self.assertIn('A new correction.', docs['release-body.md'])
|
|
||||||
|
|
||||||
def test_badges_are_images_with_escaped_attributes(self):
|
def test_badges_are_images_with_escaped_attributes(self):
|
||||||
rendered = export.inline(' **Camera**')
|
rendered = export.inline(' **Camera**')
|
||||||
self.assertIn('<img class="cl-badge"', rendered)
|
self.assertIn('<img class="cl-badge"', rendered)
|
||||||
self.assertIn('alt="Fix"', rendered)
|
self.assertIn('alt="Fix"', rendered)
|
||||||
self.assertIn('<strong>Camera</strong>', rendered)
|
self.assertIn('<strong>Camera</strong>', rendered)
|
||||||
|
|
@ -111,11 +79,6 @@ class ChangelogTests(unittest.TestCase):
|
||||||
self.assertNotIn('<img', export.inline(''))
|
self.assertNotIn('<img', export.inline(''))
|
||||||
self.assertNotIn(' onerror="', export.inline(''))
|
self.assertNotIn(' onerror="', export.inline(''))
|
||||||
|
|
||||||
def test_website_badges_do_not_require_external_images(self):
|
|
||||||
rendered = export.inline('')
|
|
||||||
self.assertEqual(rendered, '<span class="cl-badge" style="background:#52677D">Fix</span>')
|
|
||||||
self.assertNotIn('<script>', export.inline(''))
|
|
||||||
|
|
||||||
def test_invalid_platform_is_rejected(self):
|
def test_invalid_platform_is_rejected(self):
|
||||||
self.source['entries'][0]['metadata']['platforms'] = ['unknown']
|
self.source['entries'][0]['metadata']['platforms'] = ['unknown']
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"""Reject stale Android payloads and inconsistent common release metadata."""
|
"""Reject stale Android payloads and inconsistent common release metadata."""
|
||||||
|
|
||||||
import argparse, contextlib, hashlib, io, json, re, zipfile
|
import argparse, contextlib, hashlib, io, json, re, zipfile
|
||||||
from project import ROOT, release_info, SOURCE_OVERRIDES
|
from project import ROOT, release_info
|
||||||
|
|
||||||
p = argparse.ArgumentParser()
|
p = argparse.ArgumentParser()
|
||||||
group = p.add_mutually_exclusive_group()
|
group = p.add_mutually_exclusive_group()
|
||||||
|
|
@ -72,7 +72,6 @@ with source as archive:
|
||||||
assert actual == expected.encode(), "Stale Android Gaia panel: " + path
|
assert actual == expected.encode(), "Stale Android Gaia panel: " + path
|
||||||
results[app + "/" + path] = hashlib.sha256(actual).hexdigest()
|
results[app + "/" + path] = hashlib.sha256(actual).hexdigest()
|
||||||
for app, path, source in [
|
for app, path, source in [
|
||||||
("keyboard", "gaia_build_defer_settings.js", "overrides"),
|
|
||||||
("calendar", "gaia_build_defer_index.js", "overrides"),
|
("calendar", "gaia_build_defer_index.js", "overrides"),
|
||||||
("calendar", "js/bundle.js", "overrides"),
|
("calendar", "js/bundle.js", "overrides"),
|
||||||
("gallery", "js/metadata_scripts.js", "overrides"),
|
("gallery", "js/metadata_scripts.js", "overrides"),
|
||||||
|
|
@ -95,13 +94,6 @@ with source as archive:
|
||||||
actual = gaia.read(app + "/" + path)
|
actual = gaia.read(app + "/" + path)
|
||||||
assert actual == expected, "Stale application workflow: " + app + "/" + path
|
assert actual == expected, "Stale application workflow: " + app + "/" + path
|
||||||
results[app + "/" + path] = hashlib.sha256(actual).hexdigest()
|
results[app + "/" + path] = hashlib.sha256(actual).hexdigest()
|
||||||
for app in ("keyboard", "settings"):
|
|
||||||
for name in SOURCE_OVERRIDES[app]:
|
|
||||||
expected = (ROOT / "gaia/apps" / app / name).read_bytes()
|
|
||||||
if name.endswith(".js"):
|
|
||||||
expected = translate(expected.decode()).encode()
|
|
||||||
assert gaia.read(app + "/" + name) == expected, "Stale shared source: " + app + "/" + name
|
|
||||||
results[app + "/" + name] = hashlib.sha256(expected).hexdigest()
|
|
||||||
helper = "dialer/js/telephony_helper.js"
|
helper = "dialer/js/telephony_helper.js"
|
||||||
expected = translate((ROOT / "gaia/apps/communications" / helper).read_text()).encode()
|
expected = translate((ROOT / "gaia/apps/communications" / helper).read_text()).encode()
|
||||||
assert gaia.read("communications/" + helper) == expected, "Stale call-log dial helper"
|
assert gaia.read("communications/" + helper) == expected, "Stale call-log dial helper"
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,6 @@ def inline(value):
|
||||||
return '<strong>' + html.escape(text[2:-2]) + '</strong>'
|
return '<strong>' + html.escape(text[2:-2]) + '</strong>'
|
||||||
if text.startswith('!['):
|
if text.startswith('!['):
|
||||||
label, url = re.fullmatch(r'!\[([^\]]+)\]\(([^)]+)\)', text).groups()
|
label, url = re.fullmatch(r'!\[([^\]]+)\]\(([^)]+)\)', text).groups()
|
||||||
badge = re.fullmatch(r'https://img\.shields\.io/badge/[^?]+-([0-9A-Fa-f]{6})\?style=flat-square', url)
|
|
||||||
if badge:
|
|
||||||
# Website badges are text: visible offline, without image-loading delays.
|
|
||||||
return '<span class="cl-badge" style="background:#' + badge[1] + '">' + html.escape(label) + '</span>'
|
|
||||||
if url.startswith('https://'):
|
if url.startswith('https://'):
|
||||||
return '<img class="cl-badge" src="' + html.escape(url, quote=True) + '" alt="' + html.escape(label, quote=True) + '" height="20" loading="lazy" style="vertical-align:middle;width:auto">'
|
return '<img class="cl-badge" src="' + html.escape(url, quote=True) + '" alt="' + html.escape(label, quote=True) + '" height="20" loading="lazy" style="vertical-align:middle;width:auto">'
|
||||||
return html.escape(label)
|
return html.escape(label)
|
||||||
|
|
@ -66,22 +62,6 @@ def parse(path):
|
||||||
return parse_text(path.read_text())
|
return parse_text(path.read_text())
|
||||||
|
|
||||||
|
|
||||||
def group_scope(name):
|
|
||||||
"""Read platform badges as labels, not as image URLs."""
|
|
||||||
label = re.sub(r'!\[([^\]]+)\]\([^)]+\)', r'\1', name)
|
|
||||||
common = label.startswith(('Commun', 'Shared', 'Toutes les plateformes', 'All Platforms'))
|
|
||||||
if common:
|
|
||||||
return label, ['pc', 'apk', 'models'], True
|
|
||||||
scope = []
|
|
||||||
if re.search(r'\bPC\b|Desktop', label): scope.append('pc')
|
|
||||||
if 'Android' in label: scope.append('apk')
|
|
||||||
if 'Sargo' in label or 'Tundra' in label: scope.append('models')
|
|
||||||
if label == 'Web': scope.append('web')
|
|
||||||
if not scope:
|
|
||||||
raise ValueError('Unknown platform group: ' + label)
|
|
||||||
return label, scope, False
|
|
||||||
|
|
||||||
|
|
||||||
def parse_text(markdown):
|
def parse_text(markdown):
|
||||||
entries = []
|
entries = []
|
||||||
months = {name: index for names in (
|
months = {name: index for names in (
|
||||||
|
|
@ -91,26 +71,23 @@ def parse_text(markdown):
|
||||||
for chunk in re.split(r'^## ', markdown, flags=re.MULTILINE)[1:]:
|
for chunk in re.split(r'^## ', markdown, flags=re.MULTILINE)[1:]:
|
||||||
heading, _, body = chunk.partition('\n')
|
heading, _, body = chunk.partition('\n')
|
||||||
if heading.startswith(('Tenue du journal', 'Maintaining this journal')): continue
|
if heading.startswith(('Tenue du journal', 'Maintaining this journal')): continue
|
||||||
title = re.sub(r'^\d{2}/\d{2}/\d{4}\s*:\s*', '', heading)
|
version = re.match(r'\d+\.\d+[.\w-]*', heading)
|
||||||
version = re.match(r'\d+\.\d+[.\w-]*', title)
|
key = version.group() if version else ('development' if heading.startswith(('En développement', 'In development')) else 'history')
|
||||||
key = version.group() if version else ('development' if title.startswith(('En développement', 'In development')) else 'history')
|
|
||||||
kind = 'version' if version else key
|
kind = 'version' if version else key
|
||||||
stamp = re.search(r'(\d{1,2}) ([A-Za-zéû]+) (\d{4})', heading)
|
stamp = re.search(r'(\d{1,2}) ([A-Za-zéû]+) (\d{4})', heading)
|
||||||
date = None
|
date = None
|
||||||
if stamp and stamp[2].lower() in months:
|
if stamp and stamp[2].lower() in months:
|
||||||
date = f'{stamp[3]}-{months[stamp[2].lower()]:02d}-{int(stamp[1]):02d}'
|
date = f'{stamp[3]}-{months[stamp[2].lower()]:02d}-{int(stamp[1]):02d}'
|
||||||
numeric_date = re.match(r'(\d{2})/(\d{2})/(\d{4})\s*:', heading)
|
|
||||||
if numeric_date:
|
|
||||||
date = f'{numeric_date[3]}-{numeric_date[2]}-{numeric_date[1]}'
|
|
||||||
names = re.findall(r'^### (.+)', body, re.MULTILINE)
|
names = re.findall(r'^### (.+)', body, re.MULTILINE)
|
||||||
platforms = [p for p in ('pc', 'apk', 'models', 'web')
|
platforms = []
|
||||||
if any(p in group_scope(name)[1] for name in names)] or ['apk']
|
if any(re.search(r'\bPC\b|Desktop', name) for name in names): platforms.append('pc')
|
||||||
|
if any('Android' in name for name in names): platforms.append('apk')
|
||||||
|
if any('Tundra' in name or 'Sargo' in name for name in names): platforms.append('models')
|
||||||
|
if not platforms:
|
||||||
|
platforms = ['pc', 'apk', 'models'] if any(name.startswith(('Commun', 'Shared')) for name in names) else ['apk']
|
||||||
status = 'development' if key == 'development' else 'history' if key == 'history' else 'published' if re.search(r'publi[ée]|published', heading, re.I) else 'built'
|
status = 'development' if key == 'development' else 'history' if key == 'history' else 'published' if re.search(r'publi[ée]|published', heading, re.I) else 'built'
|
||||||
release = re.search(r'^\[Release\]\((https://[^)]+)\)', body, re.MULTILINE)
|
release = re.search(r'^\[Release\]\((https://[^)]+)\)', body, re.MULTILINE)
|
||||||
body = re.sub(r'^\[Release\]\([^)]+\)\s*', '', body, flags=re.MULTILINE)
|
body = re.sub(r'^\[Release\]\([^)]+\)\s*', '', body, flags=re.MULTILINE)
|
||||||
# The website already displays the status beside the date/version.
|
|
||||||
body = re.sub(r'^!\[(?:Publié|Published|En développement|In development|APK construit · non publié|APK built · not published)\]\([^)]+\)\s*',
|
|
||||||
'', body, flags=re.MULTILINE)
|
|
||||||
parts = re.split(r'^### (.+)\n', body, flags=re.MULTILINE)
|
parts = re.split(r'^### (.+)\n', body, flags=re.MULTILINE)
|
||||||
groups = []
|
groups = []
|
||||||
intro = blocks(parts[0].splitlines())
|
intro = blocks(parts[0].splitlines())
|
||||||
|
|
@ -118,11 +95,16 @@ def parse_text(markdown):
|
||||||
groups.append({'name': '', 'platforms': platforms, 'common': False, 'html': intro})
|
groups.append({'name': '', 'platforms': platforms, 'common': False, 'html': intro})
|
||||||
intro = ''
|
intro = ''
|
||||||
for name, text in zip(parts[1::2], parts[2::2]):
|
for name, text in zip(parts[1::2], parts[2::2]):
|
||||||
label, scope, common = group_scope(name)
|
common = name.startswith(('Commun', 'Shared'))
|
||||||
|
scope = platforms if common else []
|
||||||
|
if not common:
|
||||||
|
if re.search(r'\bPC\b|Desktop', name): scope.append('pc')
|
||||||
|
if 'Android' in name: scope.append('apk')
|
||||||
|
if 'Sargo' in name or 'Tundra' in name: scope.append('models')
|
||||||
|
if not scope: scope = ['pc']
|
||||||
# Closing limits belong to the whole entry, not to the last group.
|
# Closing limits belong to the whole entry, not to the last group.
|
||||||
split = re.split(r'(?=^\*\*(?:Limites|Limitations) :)|(?=^\*\*Limitations:)', text, maxsplit=1, flags=re.MULTILINE)
|
split = re.split(r'(?=^\*\*(?:Limites|Limitations) :)|(?=^\*\*Limitations:)', text, maxsplit=1, flags=re.MULTILINE)
|
||||||
groups.append({'name': label, 'nameHtml': inline(name), 'platforms': scope,
|
groups.append({'name': name, 'platforms': scope, 'common': common, 'html': blocks(split[0].splitlines())})
|
||||||
'common': common, 'html': blocks(split[0].splitlines())})
|
|
||||||
if len(split)>1: intro += blocks(split[1].splitlines())
|
if len(split)>1: intro += blocks(split[1].splitlines())
|
||||||
entries.append({'id': key, 'kind': kind, 'date': date, 'title': heading, 'status': status, 'platforms': platforms,
|
entries.append({'id': key, 'kind': kind, 'date': date, 'title': heading, 'status': status, 'platforms': platforms,
|
||||||
'models': ['sargo'] if 'models' in platforms else [], 'notes': intro,
|
'models': ['sargo'] if 'models' in platforms else [], 'notes': intro,
|
||||||
|
|
@ -166,7 +148,7 @@ def load_source(path):
|
||||||
require(metadata['status'] in ('development', 'history', 'built', 'published'), 'Invalid entry status: ' + key)
|
require(metadata['status'] in ('development', 'history', 'built', 'published'), 'Invalid entry status: ' + key)
|
||||||
require(isinstance(metadata['platforms'], list) and bool(metadata['platforms']) and
|
require(isinstance(metadata['platforms'], list) and bool(metadata['platforms']) and
|
||||||
len(set(metadata['platforms'])) == len(metadata['platforms']) and
|
len(set(metadata['platforms'])) == len(metadata['platforms']) and
|
||||||
set(metadata['platforms']) <= {'pc', 'apk', 'models', 'web'}, 'Invalid platforms: ' + key)
|
set(metadata['platforms']) <= {'pc', 'apk', 'models'}, 'Invalid platforms: ' + key)
|
||||||
require(isinstance(metadata['models'], list) and
|
require(isinstance(metadata['models'], list) and
|
||||||
all(isinstance(model, str) and re.fullmatch(r'[a-z0-9-]+', model) for model in metadata['models']) and
|
all(isinstance(model, str) and re.fullmatch(r'[a-z0-9-]+', model) for model in metadata['models']) and
|
||||||
(not metadata['models'] or 'models' in metadata['platforms']), 'Invalid models: ' + key)
|
(not metadata['models'] or 'models' in metadata['platforms']), 'Invalid models: ' + key)
|
||||||
|
|
@ -180,9 +162,6 @@ def load_source(path):
|
||||||
# Flat changelogs keep scope independently of translated prose.
|
# Flat changelogs keep scope independently of translated prose.
|
||||||
if len(item['groups']) == 1 and not item['groups'][0]['name']:
|
if len(item['groups']) == 1 and not item['groups'][0]['name']:
|
||||||
item['groups'][0]['platforms'] = metadata['platforms']
|
item['groups'][0]['platforms'] = metadata['platforms']
|
||||||
else:
|
|
||||||
require(set(metadata['platforms']) == {p for g in item['groups'] for p in g['platforms']},
|
|
||||||
'Group/entry platform mismatch: ' + key)
|
|
||||||
if item['kind'] == 'version':
|
if item['kind'] == 'version':
|
||||||
require(item['id'] == key, 'Version/ID mismatch: ' + key)
|
require(item['id'] == key, 'Version/ID mismatch: ' + key)
|
||||||
# Development IDs are fixed in the source, independent of order/date.
|
# Development IDs are fixed in the source, independent of order/date.
|
||||||
|
|
@ -209,19 +188,15 @@ def release_documents(source, tag):
|
||||||
histories = {}
|
histories = {}
|
||||||
for lang in LANGUAGES:
|
for lang in LANGUAGES:
|
||||||
title = 'Historique cumulatif' if lang == 'fr' else 'Cumulative history'
|
title = 'Historique cumulatif' if lang == 'fr' else 'Cumulative history'
|
||||||
note = ('Chaque entrée décrit la situation à sa date. Consulter la synthèse de la release pour son état final.'
|
note = ('Les sections datées décrivent l’état à chaque étape. Les limites et correctifs ont évolué ; '
|
||||||
if lang == 'fr' else
|
'consulter la synthèse de la release pour l’état final. Les chemins de documentation désignent '
|
||||||
'Each entry describes the state at its date. See the release summary for its final state.')
|
'les sources locales, dont la publication est distincte de cet APK.' if lang == 'fr' else
|
||||||
|
'Dated sections describe each development stage. Limitations and fixes evolved; the release '
|
||||||
|
'summary describes the final state. Documentation paths refer to local sources; source '
|
||||||
|
'publication is separate from this APK.')
|
||||||
histories[lang] = (f'# {title} — {release["after"]} → {release["version"]}\n\n{note}\n\n' +
|
histories[lang] = (f'# {title} — {release["after"]} → {release["version"]}\n\n{note}\n\n' +
|
||||||
'\n\n'.join(e['text'][lang].strip() for e in selected) + '\n')
|
'\n\n'.join(e['text'][lang].strip() for e in selected) + '\n')
|
||||||
body = release['summary'].strip() + '\n\n'
|
body = release['summary'].strip() + '\n\n'
|
||||||
if release.get('include_entry'):
|
|
||||||
for lang, label in [('fr', '🇫🇷 Français'), ('en', '🇬🇧 English')]:
|
|
||||||
summary = selected[0]['text'][lang].strip()
|
|
||||||
# Historical build status remains in the dated history, not in a published release's summary.
|
|
||||||
summary = re.sub(r'^!\[(?:APK construit · non publié|APK built · not published)\]\([^)]+\)\s*',
|
|
||||||
'', summary, flags=re.MULTILINE)
|
|
||||||
body += label + '\n\n' + summary + '\n\n'
|
|
||||||
for lang, label in [('fr', '🇫🇷 Historique complet, étape par étape'),
|
for lang, label in [('fr', '🇫🇷 Historique complet, étape par étape'),
|
||||||
('en', '🇬🇧 Full history, stage by stage')]:
|
('en', '🇬🇧 Full history, stage by stage')]:
|
||||||
body += f'<details>\n<summary>{label}</summary>\n\n{histories[lang]}\n</details>\n\n'
|
body += f'<details>\n<summary>{label}</summary>\n\n{histories[lang]}\n</details>\n\n'
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,6 @@ SOURCE_OVERRIDES = {
|
||||||
"system": ["js/launcher.js", "js/gaia_scheduler.js", "js/keyboard_manager.js",
|
"system": ["js/launcher.js", "js/gaia_scheduler.js", "js/keyboard_manager.js",
|
||||||
"js/core.js", "js/rocketbar.js"],
|
"js/core.js", "js/rocketbar.js"],
|
||||||
"communications": ["dialer/js/telephony_helper.js"],
|
"communications": ["dialer/js/telephony_helper.js"],
|
||||||
"settings": ["js/modules/storage_helper.js", "js/startup.js"],
|
|
||||||
"keyboard": ["js/settings/layout_dictionary_downloader.js"] + [
|
|
||||||
str(path.relative_to(ROOT / "gaia/apps/keyboard"))
|
|
||||||
for path in sorted((ROOT / "gaia/apps/keyboard/js/imes/latin/dictionaries").glob("*.dict"))
|
|
||||||
],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
#!/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,7 +12,6 @@ modules = [
|
||||||
('panels/wifi/wifi_network_list', 'js/panels/wifi/panel.js'),
|
('panels/wifi/wifi_network_list', 'js/panels/wifi/panel.js'),
|
||||||
('modules/page_transitions', 'js/main.js'),
|
('modules/page_transitions', 'js/main.js'),
|
||||||
('modules/settings_service', 'js/main.js'),
|
('modules/settings_service', 'js/main.js'),
|
||||||
('modules/panel_cache', 'js/main.js'),
|
|
||||||
('modules/dialog_service', 'js/modules/dialog_service.js'),
|
('modules/dialog_service', 'js/modules/dialog_service.js'),
|
||||||
('modules/dialog_manager', 'js/modules/dialog_service.js'),
|
('modules/dialog_manager', 'js/modules/dialog_service.js'),
|
||||||
('modules/date_time', 'js/modules/date_time.js'),
|
('modules/date_time', 'js/modules/date_time.js'),
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
#!/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