From 200d1fb235d727c038cbb4a423b41887f055b6e8 Mon Sep 17 00:00:00 2001 From: OverSu Date: Wed, 14 Oct 2026 13:13:46 +0200 Subject: [PATCH] Release Android Preview 21 and bilingual changelog Add permission-based reading of Android contacts, SMS and call history. Include shared Gaia fixes and document device validation limits. Pixel image qualification remains pending. --- CHANGELOG.en.md | 25 + CHANGELOG.md | 25 + android/app/src/main/AndroidManifest.xml | 4 + .../preview/AndroidPersonalData.java | 172 ++++ .../vulpes_os/preview/AndroidServices.java | 26 + .../org/vulpes_os/preview/MainActivity.java | 11 +- .../vulpes_os/preview/VulpesApplication.java | 10 + android/extension/background.js | 28 +- android/extension/content.js | 42 +- android/extension/personal-data.mjs | 88 ++ android/tools/probe-android.py | 27 +- android/tools/rdp.py | 2 + android/ui/android-ui.js | 203 +++- .../dialer/gaia_build_defer_index.js | 2 +- .../dialer/js/call_log.js | 2 +- .../marketplace.gaiamobile.org/marketplace.js | 6 + changelog.json | 26 + .../apps/communications/dialer/js/call_log.js | 3 +- .../settings/layout_dictionary_downloader.js | 5 +- gaia/apps/marketplace/marketplace.js | 6 + gaia/apps/settings/js/modules/panel_cache.js | 13 +- .../settings/js/modules/settings_service.js | 7 +- gaia/apps/settings/js/startup.js | 13 +- host/gaia-input.js | 11 +- host/gaia-theme.css | 22 + .../gaia_build_defer_settings.js | 966 ++++++++++++++++++ overrides/settings.gaiamobile.org/js/main.js | 129 ++- release.json | 6 +- tests/android-input-bridge.test.mjs | 65 +- tests/android-personal-data.test.mjs | 59 ++ tests/android-personal-ui.test.mjs | 85 ++ tests/call-log-contacts.test.mjs | 20 + tests/keyboard-dictionaries.test.mjs | 24 + tests/keyboard-startup.test.mjs | 40 + tests/marketplace-navigation.test.mjs | 34 + tests/settings-panel-cache.test.mjs | 29 + tests/settings-startup.test.mjs | 19 + tests/test_changelog.py | 4 +- tools/check-parity.py | 10 +- tools/project.py | 5 + tools/refresh-keyboard.py | 13 + tools/refresh-settings-panels.py | 1 + tools/test-ticket-settings.py | 48 + 43 files changed, 2271 insertions(+), 65 deletions(-) create mode 100644 android/app/src/main/java/org/vulpes_os/preview/AndroidPersonalData.java create mode 100644 android/extension/personal-data.mjs create mode 100644 overrides/keyboard.gaiamobile.org/gaia_build_defer_settings.js create mode 100644 tests/android-personal-data.test.mjs create mode 100644 tests/android-personal-ui.test.mjs create mode 100644 tests/call-log-contacts.test.mjs create mode 100644 tests/keyboard-dictionaries.test.mjs create mode 100644 tests/keyboard-startup.test.mjs create mode 100644 tests/marketplace-navigation.test.mjs create mode 100644 tests/settings-panel-cache.test.mjs create mode 100644 tests/settings-startup.test.mjs create mode 100644 tools/refresh-keyboard.py create mode 100644 tools/test-ticket-settings.py diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md index 4e77e953..5a7cf331 100644 --- a/CHANGELOG.en.md +++ b/CHANGELOG.en.md @@ -4,6 +4,31 @@ Updates by date and platform. Within each group: Add, Change, Fix, Note, Test. Entries describe the state at their date; a fix in development requires an updated installed version. + + +## 14/10/2026 : 2.7-preview.21 + +![APK published](https://img.shields.io/badge/APK%20published-2F855A?style=flat-square) + +### ![All Platforms](https://img.shields.io/badge/All%20Platforms-2563EB?style=flat-square) + +- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Appearance](https://img.shields.io/badge/Appearance-475569?style=flat-square) : Unavailable-feature messages are easier to read in light and dark themes. +- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Call history](https://img.shields.io/badge/Call%20history-475569?style=flat-square) : Contact names now appear when the call history first loads. +- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Keyboard](https://img.shields.io/badge/Keyboard-475569?style=flat-square) : Additional dictionaries available offline; improved Gaia keyboard activation and recovery. Verification on affected devices is still needed. +- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Settings](https://img.shields.io/badge/Settings-475569?style=flat-square) : Storage panels restored. A loading error no longer locks the other menus. + +### ![Android / APK](https://img.shields.io/badge/Android%20%2F%20APK-2563EB?style=flat-square) + +- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Android data](https://img.shields.io/badge/Android%20data-475569?style=flat-square) : Read Android contacts, SMS and call history in Gaia after granting access. Existing local Vulpes data is preserved. +- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Marketplace](https://img.shields.io/badge/Marketplace-475569?style=flat-square) : Return to Vulpes without a persistent white overlay; a closed catalogue can be reopened. +- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Keyboard](https://img.shields.io/badge/Keyboard-475569?style=flat-square) : The reported blank keyboard still needs reproduction on the affected devices. +- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Android data](https://img.shields.io/badge/Android%20data-475569?style=flat-square) : First, read-only integration, excluding MMS. Sending and editing Android records use Android apps; new Gaia contacts stay local. SMS and call log access depend on ROM and installer permissions. Tested on Android 9; other versions need verification. + +### ![Sargo / Pixel 3a](https://img.shields.io/badge/Sargo%20%2F%20Pixel%203a-2563EB?style=flat-square) + +- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Shared Gaia fixes are included in full installation images and boot updates. +- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : New installation image built; Pixel boot and flashing must be tested before publication. + ## 13/10/2026 : 2.7-preview.20 diff --git a/CHANGELOG.md b/CHANGELOG.md index eb99da3f..f047bd5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,31 @@ Les nouveautés, par date et par plateforme. Dans chaque groupe : Add, Change, Fix, Note, Test. Les entrées décrivent la situation à leur date ; une correction en développement nécessite une nouvelle version installée. + + +## 14/10/2026 : 2.7-preview.21 + +![APK publié](https://img.shields.io/badge/APK%20publi%C3%A9-2F855A?style=flat-square) + +### ![Toutes les plateformes](https://img.shields.io/badge/Toutes%20les%20plateformes-2563EB?style=flat-square) + +- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Affichage](https://img.shields.io/badge/Affichage-475569?style=flat-square) : Messages des fonctions indisponibles plus lisibles en thèmes clair et sombre. +- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Clavier](https://img.shields.io/badge/Clavier-475569?style=flat-square) : Dictionnaires supplémentaires disponibles hors ligne ; activation et reprise du clavier Gaia fiabilisées. La vérification sur les appareils concernés reste nécessaire. +- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Journal d’appels](https://img.shields.io/badge/Journal%20d%E2%80%99appels-475569?style=flat-square) : Affichage du nom des contacts rétabli au premier chargement du journal. +- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Paramètres](https://img.shields.io/badge/Param%C3%A8tres-475569?style=flat-square) : Panneaux Stockage rétablis. Une erreur de chargement ne bloque plus les autres menus. + +### ![Android / APK](https://img.shields.io/badge/Android%20%2F%20APK-2563EB?style=flat-square) + +- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Données Android](https://img.shields.io/badge/Donn%C3%A9es%20Android-475569?style=flat-square) : Lecture des contacts, SMS et journaux Android dans Gaia, après autorisation. Les données locales de Vulpes sont conservées. +- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Marketplace](https://img.shields.io/badge/Marketplace-475569?style=flat-square) : Retour vers Vulpes sans écran blanc persistant ; un catalogue fermé peut être rouvert. +- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Clavier](https://img.shields.io/badge/Clavier-475569?style=flat-square) : Le rectangle blanc signalé reste à reproduire sur les appareils concernés. +- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Données Android](https://img.shields.io/badge/Donn%C3%A9es%20Android-475569?style=flat-square) : Première étape en lecture seule, hors MMS. Envoi et édition des données Android dans les applications Android ; les nouveaux contacts Gaia restent locaux. Accès SMS et journal selon les autorisations de la ROM et de l’installateur. Essais sur Android 9 ; autres versions à confirmer. + +### ![Sargo / Pixel 3a](https://img.shields.io/badge/Sargo%20%2F%20Pixel%203a-2563EB?style=flat-square) + +- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Les corrections communes de Gaia sont incluses dans les images complètes et les mises à jour de démarrage. +- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Nouvelle image d’installation construite ; démarrage et flash sur Pixel à vérifier avant publication. + ## 13/10/2026 : 2.7-preview.20 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fae6411a..4eb33b18 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,9 @@ + + + + diff --git a/android/app/src/main/java/org/vulpes_os/preview/AndroidPersonalData.java b/android/app/src/main/java/org/vulpes_os/preview/AndroidPersonalData.java new file mode 100644 index 00000000..ad17c798 --- /dev/null +++ b/android/app/src/main/java/org/vulpes_os/preview/AndroidPersonalData.java @@ -0,0 +1,172 @@ +package org.vulpes_os.preview; + +import android.Manifest; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.database.ContentObserver; +import android.os.Handler; +import android.os.Looper; +import java.util.HashMap; +import java.util.Map; +import android.net.Uri; +import android.provider.CallLog; +import android.provider.ContactsContract; +import android.provider.Telephony; +import org.json.JSONArray; +import org.json.JSONObject; + +/** Read-through access. Android remains the owner of contacts and telephony records. */ +final class AndroidPersonalData { + private final Context context; + private final Handler main = new Handler(Looper.getMainLooper()); + private final Map observers = new HashMap<>(); + private final Map 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"); + } +} diff --git a/android/app/src/main/java/org/vulpes_os/preview/AndroidServices.java b/android/app/src/main/java/org/vulpes_os/preview/AndroidServices.java index 64020f99..8aded31a 100644 --- a/android/app/src/main/java/org/vulpes_os/preview/AndroidServices.java +++ b/android/app/src/main/java/org/vulpes_os/preview/AndroidServices.java @@ -24,6 +24,7 @@ final class AndroidServices { CONTACT = 412; static final long MAX_MEDIA = 32L * 1024 * 1024; final VulpesApplication app; + final AndroidPersonalData personal; final ExecutorService io = Executors.newSingleThreadExecutor(); final Handler main = new Handler(Looper.getMainLooper()); GeckoResult pending; @@ -31,6 +32,7 @@ final class AndroidServices { AndroidServices(VulpesApplication app) { this.app = app; + personal = new AndroidPersonalData(app); } GeckoResult request(JSONObject message) { @@ -40,6 +42,30 @@ final class AndroidServices { JSONObject p = message.optJSONObject("params"); if (p == null) p = new JSONObject(); switch (op) { + case "android.personal": + JSONObject personalParams = p; + String operation = p.optString("operation"); + if (operation.equals("permission")) { + String kind = p.getString("kind"); + activity().permission(new String[]{AndroidPersonalData.permission(kind)}, granted -> { + try { personal.enable(kind, granted); result.complete(personal.status()); } catch (Exception e) { fail(result, e); } + }); + } else if (operation.equals("status")) result.complete(personal.status()); + else if (operation.equals("disconnect")) { + personal.enable(p.getString("kind"), false); + result.complete(personal.status()); + } + else if (operation.equals("open")) { + activity().startActivity(personal.open(p)); + result.complete(null); + } else if (operation.equals("page")) io.execute(() -> { + try { + JSONObject data = personal.page(personalParams); + main.post(() -> result.complete(data)); + } catch (Exception e) { fail(result, e); } + }); + else throw new IllegalArgumentException("METHOD_NOT_SUPPORTED"); + break; case "android.notification": JSONObject request = p; if (p.optString("operation").equals("permission")) { diff --git a/android/app/src/main/java/org/vulpes_os/preview/MainActivity.java b/android/app/src/main/java/org/vulpes_os/preview/MainActivity.java index 640a5578..458c8586 100644 --- a/android/app/src/main/java/org/vulpes_os/preview/MainActivity.java +++ b/android/app/src/main/java/org/vulpes_os/preview/MainActivity.java @@ -82,8 +82,8 @@ public final class MainActivity extends Activity { webPanel.addView(nav, new LinearLayout.LayoutParams(-1, dp(52))); Button back = new Button(this); back.setText("‹"); - back.setContentDescription("Retour au bureau"); - back.setOnClickListener(v -> closeBrowser()); + back.setContentDescription("Retour"); + back.setOnClickListener(v -> back()); nav.addView(back, new LinearLayout.LayoutParams(dp(55), -1)); address = new EditText(this); address.setSingleLine(true); @@ -99,6 +99,9 @@ public final class MainActivity extends Activity { return true; }); webView = new GeckoView(this); + // The web panel is layered over Gaia and hidden when returning to an app. + // A TextureView follows that visibility without leaving a separate surface. + webView.setViewBackend(GeckoView.BACKEND_TEXTURE_VIEW); webPanel.addView(webView, new LinearLayout.LayoutParams(-1, 0, 1)); home = new Button(this); home.setText("◯"); @@ -297,10 +300,12 @@ public final class MainActivity extends Activity { } void closeBrowser() { + boolean wasVisible = webPanel.getVisibility() == View.VISIBLE; webPanel.setVisibility(View.GONE); gaiaView.setVisibility(View.VISIBLE); setHomeSurface(app.homeVisible); syncActive(); + if (wasVisible) app.browserClosed(); } private void back() { @@ -470,7 +475,7 @@ public final class MainActivity extends Activity { else callback.accept(granted); } - private void permission(String[] names, java.util.function.Consumer callback) { + void permission(String[] names, java.util.function.Consumer callback) { boolean granted = true; for (String name : names) granted &= checkSelfPermission(name) == PackageManager.PERMISSION_GRANTED; diff --git a/android/app/src/main/java/org/vulpes_os/preview/VulpesApplication.java b/android/app/src/main/java/org/vulpes_os/preview/VulpesApplication.java index 3a46db7e..4e44e8ea 100644 --- a/android/app/src/main/java/org/vulpes_os/preview/VulpesApplication.java +++ b/android/app/src/main/java/org/vulpes_os/preview/VulpesApplication.java @@ -150,6 +150,15 @@ public final class VulpesApplication extends Application { ); } + void browserClosed() { + if (port == null) return; + try { + port.postMessage(new JSONObject().put("type", "browser-closed")); + } catch (Exception e) { + Log.w("Vulpes", "Browser close notification", e); + } + } + void home(boolean held) { if (port == null) return; try { @@ -160,6 +169,7 @@ public final class VulpesApplication extends Application { } void resumed() { + if (services != null) services.personal.observe(); sendScreenLock(); if (port == null) return; try { diff --git a/android/extension/background.js b/android/extension/background.js index 4baab916..e8d628e4 100644 --- a/android/extension/background.js +++ b/android/extension/background.js @@ -1,3 +1,4 @@ +import {AndroidPersonalData, authorizePersonal} from './personal-data.mjs'; import {Input} from './services/input.mjs'; import { Packages, packageURL } from './services/packages.mjs'; import { Activities, isGaiaActivity } from './services/activities.mjs'; @@ -15,7 +16,7 @@ const input = new Input({identity:peer=>peer.app,broadcast}); let editSequence=0; const nativePackages=(operation,data={})=>browser.runtime.sendNativeMessage('vulpes',{type:'packages.'+operation,...data}).then(JSON.parse); async function refreshInstalled(){const installed=await nativePackages('list');apps.splice(0,apps.length,...apps.filter(a=>!a.installed),...installed);} -let apps, values, settings, dataStores, contacts, messages, connections, alarms, storage; +let apps, values, settings, dataStores, contacts, messages, connections, alarms, storage, personal; const identity = (peer) => peer.app; function notify(peer, type, data) { try { @@ -121,6 +122,7 @@ const ready = (async () => { contacts = new Contacts(storage, (data) => broadcast('contactchange', data)); await contacts.init(); messages = new Messages(storage); + personal = new AndroidPersonalData(params=>android("personal",params), contacts, messages, storage); dataStores = new DataStoreService(apps, storage, (data) => broadcast('datastorechange', data)); connections = new Connections(identity, notify); alarms = new Alarms( @@ -140,6 +142,7 @@ const ready = (async () => { })(); // Java owns this port. Web pages have no access to native messaging. let native; +let activeWebView; let systemReady = false; let pendingScreenLock = false; function deliverScreenLock() { @@ -163,15 +166,26 @@ function connectNative() { native.onDisconnect.addListener(() => setTimeout(connectNative, 1000)); native.onMessage.addListener(async (message) => { await ready; + if (message.type === 'browser-closed' && activeWebView) { + const {peer, contextId} = activeWebView; + activeWebView = null; + notify(peer, 'view', {contextId, type:'close', detail:{}}); + } if (['home', 'holdhome'].includes(message.type)) broadcast(message.type, {}, 'system'); if (message.type === 'android-screen-off') { pendingScreenLock = true; deliverScreenLock(); } + if (message.type === 'android-data-changed') { + broadcast('android-data-changed', {kind:message.kind}, message.kind === 'sms' ? 'sms' : 'communications'); + } if (message.type === 'android-resume') { deliverScreenLock(); broadcast('screen-on', {}, 'system'); await refreshAndroid().catch(console.warn); + broadcast('android-data-changed', {kind:'contacts'}, 'communications'); + broadcast('android-data-changed', {kind:'calls'}, 'communications'); + broadcast('android-data-changed', {kind:'sms'}, 'sms'); } if (message.type === 'notification-click') { pendingNotification = message.data; @@ -215,6 +229,12 @@ async function handle(peer, request) { authorizePlatform(app, method.slice(9), params); method = 'android.' + method.slice(9); } + if (method === 'android.personal') { + authorizePersonal(app, params.kind, params.operation); + if (params.operation === 'page') return personal.rows(params.kind); + if (!['status','permission','disconnect','open'].includes(params.operation)) throw Error('METHOD_NOT_SUPPORTED'); + return android('personal', params); + } if (method === 'android.capabilities') { const release = await (await fetch('release.json')).json(); const device = app.id === 'settings' ? await android('snapshot') : {}; @@ -355,8 +375,8 @@ async function handle(peer, request) { } if (method === 'datastores.list') return dataStores.list(app, params.name); if (method === 'datastores.run') return dataStores.run(app, params); - if (method === 'contacts.request') return contacts.request(app, params); - if (method === 'messages.request') return messages.request(app, params); + if (method === 'contacts.request') return personal.contactRequest(app, params); + if (method === 'messages.request') return personal.messageRequest(app, params); if (method === 'alarms.request') return alarms.request(peer, app, params); if (method === 'iac.subscribe') { connections.subscribe(peer); @@ -397,6 +417,7 @@ async function handle(peer, request) { throw Error('PERMISSION_DENIED'); if (params.operation === 'open') { await openWeb(params.url); + activeWebView = {peer, contextId:params.contextId}; notify(peer, 'view', { contextId: params.contextId, type: 'loadend', @@ -502,6 +523,7 @@ browser.runtime.onConnect.addListener((port) => { attached .then(() => { peers.delete(peer); + if (activeWebView?.peer === peer) activeWebView = null; input.cleanup(peer); for(const edit of peer.edits.values()) {clearTimeout(edit.timer);edit.reject(Error('INPUT_CONTEXT_EXPIRED'));} peer.edits.clear(); diff --git a/android/extension/content.js b/android/extension/content.js index 4c1b7d5c..745fbb1e 100644 --- a/android/extension/content.js +++ b/android/extension/content.js @@ -37,10 +37,8 @@ } if (message.event) { if(message.event.type==='input-mode') { - gaiaKeyboard=message.event.data.enabled===true; - if(!gaiaKeyboard) restoreMode(); - win.VulpesNativeInput=gaiaKeyboard; - win.dispatchEvent(new window.Event('vulpes-input-configured')); + inputConfigurationRevision++; + configureInput(message.event.data.enabled===true); } win.dispatchEvent( new window.CustomEvent('vulpes-service-event', { detail: cloneInto(message.event, win) }), @@ -53,6 +51,8 @@ callback.resolve(cloneInto(message.reply, win)); } function onDisconnect() { + inputConfigurationRevision++; + configureInput(false); for (const callback of pending.values()) callback.reject(new window.Error('VULPES_HOST_CLOSED')); pending.clear(); @@ -60,14 +60,15 @@ reconnect = setTimeout(() => { try { connect(); + refreshInputConfiguration(); } catch (_) { onDisconnect(); } }, 1000); } - connect(); const editor = new InputEditor(window); let gaiaKeyboard = false; + let inputConfigurationRevision = 0; const originalModes = new WeakMap(); function restoreMode() { const field = editor.inputElement; @@ -77,21 +78,27 @@ else field.setAttribute('inputmode', value); originalModes.delete(field); } - request({version:1,id:0,method:'input.config',params:{}}).then(reply => { - gaiaKeyboard = reply.result === true; + function configureInput(enabled) { + gaiaKeyboard = enabled; + if(!enabled) restoreMode(); win.VulpesNativeInput = gaiaKeyboard; win.dispatchEvent(new window.Event('vulpes-input-configured')); - }).catch(error => console.error('Gaia input configuration:', error)); - for (const type of ['focusin', 'pointerdown', 'click']) addEventListener(type, event => { - if (!gaiaKeyboard || editor.editing || location.hostname === 'keyboard.localhost') return; - let element = event.target; + if(enabled && !document.hidden && document.hasFocus()) reportFocus(document.activeElement); + } + function refreshInputConfiguration() { + const revision = inputConfigurationRevision; + request({version:1,id:0,method:'input.config',params:{}}).then(reply => { + if(revision===inputConfigurationRevision) configureInput(reply.result===true); + }).catch(error => console.error('Gaia input configuration:', error)); + } + function reportFocus(element) { + if (!gaiaKeyboard || editor.editing || location.hostname === 'keyboard.localhost') return false; if (element?.isContentEditable) element=element.closest('[contenteditable=true]') || element; const editable = element?.isContentEditable || (['input','textarea'].includes(element?.localName) && !['button','submit','range','checkbox','radio','date','time','datetime-local','file'].includes(element.type)); if (!editable || element.disabled || element.readOnly) { - if (type === 'click' && !element?.closest?.('[data-vulpes-keep-input]')) port.postMessage({inputFocus:null}); - return; + return false; } if (element !== editor.inputElement) restoreMode(); editor.focus(element); @@ -101,9 +108,18 @@ const state=editor.state(); state.inputMode=originalModes.get(element) || (element.classList.contains('recipient')?'tel':''); port.postMessage({inputFocus:state}); + return true; + } + connect(); + refreshInputConfiguration(); + for (const type of ['focusin', 'pointerdown', 'click']) addEventListener(type, event => { + if (!gaiaKeyboard || editor.editing || location.hostname === 'keyboard.localhost') return; + if (!reportFocus(event.target) && type === 'click' && + !event.target?.closest?.('[data-vulpes-keep-input]')) port.postMessage({inputFocus:null}); }, true); addEventListener('visibilitychange', () => { if (document.hidden && gaiaKeyboard) { restoreMode(); port.postMessage({inputFocus:null}); } + else if (gaiaKeyboard && document.hasFocus()) reportFocus(document.activeElement); }); addEventListener('unload', () => clearTimeout(reconnect)); exportFunction(request, win.navigator, { defineAs: 'vulpesRequest' }); diff --git a/android/extension/personal-data.mjs b/android/extension/personal-data.mjs new file mode 100644 index 00000000..b97e1c9a --- /dev/null +++ b/android/extension/personal-data.mjs @@ -0,0 +1,88 @@ +import {Contacts} from './Contacts.mjs'; +import {Messages} from './Messages.mjs'; + +export function authorizePersonal(app, kind, operation) { + if (app.installed || !['settings', 'communications', 'sms', 'system'].includes(app.id)) + throw Error('PERMISSION_DENIED'); + if (operation === 'status' && app.id === 'settings') return; + if (!['contacts', 'sms', 'calls'].includes(kind)) throw Error('INVALID_DATA_KIND'); + if (['permission', 'disconnect'].includes(operation)) { + if (app.id !== 'settings') throw Error('PERMISSION_DENIED'); + return; + } + if (kind === 'contacts' && !app.manifest.permissions?.contacts?.access?.includes('read')) throw Error('PERMISSION_DENIED'); + if (kind === 'sms' && app.id !== 'sms') throw Error('PERMISSION_DENIED'); + if (kind === 'calls' && app.id !== 'communications') throw Error('PERMISSION_DENIED'); +} + +export function contactFromAndroid(row) { + return {id:'android:'+row._id, name:[row.display_name || ''], givenName:row.givenName || [], + familyName:row.familyName || [], tel:row.tel || [], email:row.email || [], + category:row.starred ? ['favorite'] : [], updated:new Date(row.contact_last_updated_timestamp || 0).toISOString()}; +} +export function smsFromAndroid(row) { + // Negative IDs keep Android provider rows separate from existing Vulpes messages. + if (![row._id, row.thread_id].every(n => Number.isSafeInteger(n) && n > 0)) throw Error('INVALID_ANDROID_ID'); + const incoming = row.type === 1; + return {id:-row._id, threadId:-row.thread_id, type:'sms', + sender:incoming ? row.address : null, receiver:incoming ? null : row.address, + body:row.body || '', timestamp:row.date, read:!!row.read, + delivery:incoming ? 'received' : row.type === 2 ? 'sent' : row.type === 5 ? 'error' : 'sending', + deliveryStatus:row.status === 0 ? 'success' : row.status >= 64 ? 'error' : 'not-applicable'}; +} + +export class AndroidPersonalData { + constructor(native, contacts, messages, storage) { + Object.assign(this, {native, contacts, messages, storage}); + } + async rows(kind) { + const status = await this.native({operation:'status'}); + if (!status[kind]) return []; + const rows = []; + let after = 0; + do { + const page = await this.native({operation:'page', kind, after}); + rows.push(...page.rows); + if (rows.length > 100000) throw Error('ANDROID_DATA_TOO_LARGE'); + if (page.next === null) break; + if (!Number.isSafeInteger(page.next) || page.next <= after) throw Error('INVALID_CURSOR'); + after = page.next; + } while (true); + return rows; + } + async contactRequest(app, params) { + const write = ['save', 'remove', 'clear'].includes(params.operation); + const access = app.manifest.permissions?.contacts?.access || ''; + if (!access.includes(write ? 'write' : 'read')) throw Error('PERMISSION_DENIED'); + // Third-party apps keep the local address book and never inherit Android's grant. + if (app.installed || !['communications','sms','system'].includes(app.id)) return this.contacts.request(app, params); + if (write) { + if (String(params.contact?.id || params.id || '').startsWith('android:') || + params.operation === 'clear' && (await this.native({operation:'status'})).contacts) + throw Error('ANDROID_DATA_READ_ONLY'); + return this.contacts.request(app, params); + } + const remote = (await this.rows('contacts')).map(contactFromAndroid); + const local = await this.contacts.request(app, {operation:'all'}); + const view = new Contacts(null, ()=>{}); + let revision = this.contacts.data.revision; + for (const c of remote) for (const character of JSON.stringify(c)) revision = (revision * 31 + character.charCodeAt(0)) >>> 0; + view.data = {revision, contacts:[...local, ...remote]}; + return view.request(app, params); + } + async messageRequest(app, params) { + if (!app.manifest.permissions?.sms) throw Error('PERMISSION_DENIED'); + if (app.installed || app.id !== 'sms') return this.messages.request(app, params); + const {operation, args=[]} = params; + if (['delete','markMessageRead'].includes(operation) && [args[0]].flat().some(id => id < 0)) + throw Error('ANDROID_DATA_READ_ONLY'); + if (!['getMessages','getThreads','getMessage'].includes(operation)) return this.messages.request(app, params); + const remote = (await this.rows('sms')).filter(row => row.type !== 3).map(smsFromAndroid); + const local = await this.messages.request(app, {operation:'getMessages'}); + const view = new Messages({run:async(_store, op)=>{ + if (op !== 'get') throw Error('ANDROID_DATA_READ_ONLY'); + return {value:[...local, ...remote]}; + }}); + return view.request(app, params); + } +} diff --git a/android/tools/probe-android.py b/android/tools/probe-android.py index f6591e57..985d5ca5 100644 --- a/android/tools/probe-android.py +++ b/android/tools/probe-android.py @@ -3,14 +3,15 @@ import json,sys,time from rdp import RDP -def evaluate(host, script): - r=RDP() +def evaluate(host, script, connection=None): + hostname, _, path = host.partition('/') + r=connection or RDP() try: for process in r.call('root','listProcesses')['processes']: if process['isParent']: continue target=r.call(process['actor'],'getTarget')['process'] if 'webIsolated' not in target.get('remoteType',''): continue - text='''(() => { const w=[...Services.ww.getWindowEnumerator()].find(w=>w.location.hostname===%s); if(!w)return null; return w.wrappedJSObject.eval(%s); })()''' % (json.dumps(host+'.localhost'),json.dumps(script)) + text='''(() => { const w=[...Services.ww.getWindowEnumerator()].find(w=>w.location.hostname===%s && (!%s || w.location.pathname.startsWith(%s))); if(!w)return null; return w.wrappedJSObject.eval(%s); })()''' % (json.dumps(hostname+'.localhost'),json.dumps(path),json.dumps('/'+path),json.dumps(script)) response=r.call(target['consoleActor'],'evaluateJSAsync',text=text) if 'resultID' not in response: continue while True: @@ -20,24 +21,32 @@ def evaluate(host, script): if value.get('result') is not None and value.get('result') != {'type':'null'}: return value.get('result') break raise RuntimeError('App not open: '+host) - finally:r.s.close() + finally: + if connection is None:r.s.close() -def run(host,expression,timeout=30): +def _run(host,expression,timeout,connection): start=time.monotonic()+timeout while True: try: - evaluate(host,'window.__androidProbe=null; Promise.resolve().then(async()=>('+expression+')).then(value=>window.__androidProbe=JSON.stringify({value}),error=>window.__androidProbe=JSON.stringify({error:String(error),stack:error.stack})); "started"') + evaluate(host,'window.__androidProbe=null; Promise.resolve().then(async()=>('+expression+')).then(value=>window.__androidProbe=JSON.stringify({value}),error=>window.__androidProbe=JSON.stringify({error:String(error),stack:error.stack})); "started"',connection) break - except (RuntimeError, EOFError, OSError) as e: + except RuntimeError as e: if time.monotonic()>=start:raise - if isinstance(e, RuntimeError) and not str(e).startswith('App not open:'):raise + if not str(e).startswith('App not open:'):raise time.sleep(.3) until=time.monotonic()+timeout while time.monotonic() { + 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(); +})(); diff --git a/assets/webapps/communications.gaiamobile.org/dialer/gaia_build_defer_index.js b/assets/webapps/communications.gaiamobile.org/dialer/gaia_build_defer_index.js index 7141e811..9a83f306 100644 --- a/assets/webapps/communications.gaiamobile.org/dialer/gaia_build_defer_index.js +++ b/assets/webapps/communications.gaiamobile.org/dialer/gaia_build_defer_index.js @@ -405,7 +405,7 @@ window.performance.measure('callLogReady','callLogStart');return;} self._empty=false;var currDate=new Date(cursor.value.date);batchGroupCounter++;if(!prevDate||(currDate.getTime()==prevDate.getTime())){chunk.push(cursor.value);}else{daysToRender.push(chunk);chunk=[cursor.value];var renderNow=false;if(batchGroupCounter>=MAX_GROUPS_FOR_FIRST_RENDER&&!screenRendered){renderNow=true;screenRendered=true;window.performance.mark('firstChunkReady');}else if(batchGroupCounter>=MAX_GROUPS_TO_BATCH_RENDER){renderNow=true;} if(renderNow){self.renderSeveralDays(daysToRender);daysToRender=[];batchGroupCounter=0;}} prevDate=currDate;cursor.continue();},'lastEntryDate',true,true);},renderSeveralDays:function cl_renderSeveralDays(chunks){for(var i=0,l=chunks.length;i=MAX_GROUPS_FOR_FIRST_RENDER&&!screenRendered){renderNow=true;screenRendered=true;window.performance.mark('firstChunkReady');}else if(batchGroupCounter>=MAX_GROUPS_TO_BATCH_RENDER){renderNow=true;} if(renderNow){self.renderSeveralDays(daysToRender);daysToRender=[];batchGroupCounter=0;}} prevDate=currDate;cursor.continue();},'lastEntryDate',true,true);},renderSeveralDays:function cl_renderSeveralDays(chunks){for(var i=0,l=chunks.length;i{ + if(busy)return; + status(text('Le catalogue est fermé. Vous pouvez le rouvrir ci-dessous.', + 'The catalogue is closed. You can reopen it below.')); + document.getElementById('open').hidden=true; + }); function browse(){labels();message.hidden=true;dialog.hidden=true;frame.style.visibility='visible';frame.src='https://vulpes-os.org/marketplace/?mode=app&lang='+(fr()?'fr':'en');} function status(value){frame.style.visibility='hidden';message.hidden=false;document.getElementById('status').textContent=value;} async function prepare(url){ diff --git a/changelog.json b/changelog.json index f71a759a..b99b823a 100644 --- a/changelog.json +++ b/changelog.json @@ -5,6 +5,25 @@ "en": "# Changelog — Vulpes OS\n\n[Français](CHANGELOG.md)\n\nUpdates by date and platform. Within each group: Add, Change, Fix, Note, Test. Entries describe the state at their date; a fix in development requires an updated installed version.\n" }, "entries": [ + { + "id": "2.7-preview.21", + "text": { + "fr": "## 14/10/2026 : 2.7-preview.21\n\n![APK publié](https://img.shields.io/badge/APK%20publi%C3%A9-2F855A?style=flat-square)\n\n### ![Toutes les plateformes](https://img.shields.io/badge/Toutes%20les%20plateformes-2563EB?style=flat-square)\n\n- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Affichage](https://img.shields.io/badge/Affichage-475569?style=flat-square) : Messages des fonctions indisponibles plus lisibles en thèmes clair et sombre.\n- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Clavier](https://img.shields.io/badge/Clavier-475569?style=flat-square) : Dictionnaires supplémentaires disponibles hors ligne ; activation et reprise du clavier Gaia fiabilisées. La vérification sur les appareils concernés reste nécessaire.\n- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Journal d’appels](https://img.shields.io/badge/Journal%20d%E2%80%99appels-475569?style=flat-square) : Affichage du nom des contacts rétabli au premier chargement du journal.\n- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Paramètres](https://img.shields.io/badge/Param%C3%A8tres-475569?style=flat-square) : Panneaux Stockage rétablis. Une erreur de chargement ne bloque plus les autres menus.\n\n### ![Android / APK](https://img.shields.io/badge/Android%20%2F%20APK-2563EB?style=flat-square)\n\n- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Données Android](https://img.shields.io/badge/Donn%C3%A9es%20Android-475569?style=flat-square) : Lecture des contacts, SMS et journaux Android dans Gaia, après autorisation. Les données locales de Vulpes sont conservées.\n- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Marketplace](https://img.shields.io/badge/Marketplace-475569?style=flat-square) : Retour vers Vulpes sans écran blanc persistant ; un catalogue fermé peut être rouvert.\n- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Clavier](https://img.shields.io/badge/Clavier-475569?style=flat-square) : Le rectangle blanc signalé reste à reproduire sur les appareils concernés.\n- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Données Android](https://img.shields.io/badge/Donn%C3%A9es%20Android-475569?style=flat-square) : Première étape en lecture seule, hors MMS. Envoi et édition des données Android dans les applications Android ; les nouveaux contacts Gaia restent locaux. Accès SMS et journal selon les autorisations de la ROM et de l’installateur. Essais sur Android 9 ; autres versions à confirmer.\n\n### ![Sargo / Pixel 3a](https://img.shields.io/badge/Sargo%20%2F%20Pixel%203a-2563EB?style=flat-square)\n\n- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Les corrections communes de Gaia sont incluses dans les images complètes et les mises à jour de démarrage.\n- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Nouvelle image d’installation construite ; démarrage et flash sur Pixel à vérifier avant publication.\n", + "en": "## 14/10/2026 : 2.7-preview.21\n\n![APK published](https://img.shields.io/badge/APK%20published-2F855A?style=flat-square)\n\n### ![All Platforms](https://img.shields.io/badge/All%20Platforms-2563EB?style=flat-square)\n\n- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Appearance](https://img.shields.io/badge/Appearance-475569?style=flat-square) : Unavailable-feature messages are easier to read in light and dark themes.\n- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Call history](https://img.shields.io/badge/Call%20history-475569?style=flat-square) : Contact names now appear when the call history first loads.\n- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Keyboard](https://img.shields.io/badge/Keyboard-475569?style=flat-square) : Additional dictionaries available offline; improved Gaia keyboard activation and recovery. Verification on affected devices is still needed.\n- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Settings](https://img.shields.io/badge/Settings-475569?style=flat-square) : Storage panels restored. A loading error no longer locks the other menus.\n\n### ![Android / APK](https://img.shields.io/badge/Android%20%2F%20APK-2563EB?style=flat-square)\n\n- ![Add](https://img.shields.io/badge/Add-2F855A?style=flat-square) ![Android data](https://img.shields.io/badge/Android%20data-475569?style=flat-square) : Read Android contacts, SMS and call history in Gaia after granting access. Existing local Vulpes data is preserved.\n- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Marketplace](https://img.shields.io/badge/Marketplace-475569?style=flat-square) : Return to Vulpes without a persistent white overlay; a closed catalogue can be reopened.\n- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Keyboard](https://img.shields.io/badge/Keyboard-475569?style=flat-square) : The reported blank keyboard still needs reproduction on the affected devices.\n- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Android data](https://img.shields.io/badge/Android%20data-475569?style=flat-square) : First, read-only integration, excluding MMS. Sending and editing Android records use Android apps; new Gaia contacts stay local. SMS and call log access depend on ROM and installer permissions. Tested on Android 9; other versions need verification.\n\n### ![Sargo / Pixel 3a](https://img.shields.io/badge/Sargo%20%2F%20Pixel%203a-2563EB?style=flat-square)\n\n- ![Fix](https://img.shields.io/badge/Fix-52677D?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : Shared Gaia fixes are included in full installation images and boot updates.\n- ![Note](https://img.shields.io/badge/Note-6B7280?style=flat-square) ![Installation](https://img.shields.io/badge/Installation-475569?style=flat-square) : New installation image built; Pixel boot and flashing must be tested before publication.\n" + }, + "metadata": { + "kind": "version", + "status": "published", + "platforms": [ + "pc", + "apk", + "models" + ], + "models": [ + "sargo" + ] + } + }, { "id": "2.7-preview.20", "text": { @@ -907,6 +926,13 @@ "url": "https://nnsprod.com/git/oversu/VulpesOS/releases/tag/android-2.7-preview.1", "summary": "# Vulpes OS — 2.7-preview.1\n\n🇫🇷 Ancienne Preview Android expérimentale. Une version plus récente est disponible dans les releases du projet.\n\n🇬🇧 Older experimental Android Preview. A newer version is available in the project releases.", "include_entry": true + }, + "v2.7-preview.21": { + "version": "2.7-preview.21", + "after": "2.7-preview.20", + "url": "https://nnsprod.com/git/oversu/VulpesOS/releases/tag/v2.7-preview.21", + "include_entry": true, + "summary": "# Vulpes OS — 2.7-preview.21\n\n🇫🇷 **Preview Android expérimentale.** Contacts, SMS et journal d’appels Android accessibles dans Gaia après autorisation, en lecture seule. Les données locales Vulpes sont conservées. Les MMS ne sont pas synchronisés ; appels, envoi et modification des données Android passent par les applications Android.\n\nInstaller l’APK par-dessus une Preview précédente signée avec la même clé. Android 8 minimum déclaré ; ARM64, ARMv7 et x86_64. Intégration vérifiée sur émulateur Android 9 avec des données de test ; les Motorola signalés et les versions Android récentes restent à qualifier. Le problème de clavier blanc n’est pas considéré comme résolu sur tous les appareils.\n\nBuild de développement signé, débogage via ADB autorisé. Gaia 2.7.0 · GeckoView Android 156.0. Vérification du téléchargement : `sha256sum -c SHA256SUMS.txt`. **Aucune nouvelle image Pixel n’est publiée ici : son flash et ses tests restent à effectuer.**\n\n🇬🇧 **Experimental Android Preview.** Android contacts, SMS and call history can be read in Gaia after granting permission. Local Vulpes data is preserved. MMS are not synchronized; calls, sending and editing Android records use Android apps.\n\nInstall over a previous Preview signed with the same key. Declared minimum Android 8; ARM64, ARMv7 and x86_64. Integration checked on an Android 9 emulator with test data; reported Motorola devices and recent Android versions still need qualification. The blank keyboard issue is not considered fixed on every device.\n\nSigned development build, debugging through authorized ADB. Gaia 2.7.0 · Android GeckoView 156.0. Verify the download with `sha256sum -c SHA256SUMS.txt`. **No new Pixel image is published here: flashing and hardware tests are still pending.**\n" } } } diff --git a/gaia/apps/communications/dialer/js/call_log.js b/gaia/apps/communications/dialer/js/call_log.js index a738f2dc..23416745 100755 --- a/gaia/apps/communications/dialer/js/call_log.js +++ b/gaia/apps/communications/dialer/js/call_log.js @@ -265,8 +265,7 @@ var CallLog = { // If the contacts cache is not valid, we retrieve the contacts information // directly from the contacts API and update the DOM accordingly. if (!this._contactCache) { - this.updateListWithContactInfo(null, null, phoneNumbers, - logGroupContainer); + this.updateListWithContactInfo(null, null, logGroupContainer); } }, diff --git a/gaia/apps/keyboard/js/settings/layout_dictionary_downloader.js b/gaia/apps/keyboard/js/settings/layout_dictionary_downloader.js index c1422c4f..b10c5e61 100755 --- a/gaia/apps/keyboard/js/settings/layout_dictionary_downloader.js +++ b/gaia/apps/keyboard/js/settings/layout_dictionary_downloader.js @@ -9,10 +9,9 @@ var LayoutDictionaryDownloader = function(imEngineId, dictFilename) { this._xhr = null; }; -// Remote URLs to download the data. We require the remote server to have -// CORS turned on since we are not asking for SystemXHR permission. +// Dictionaries ship with Vulpes so adding a layout also works offline. LayoutDictionaryDownloader.prototype.REMOTE_URL = - 'https://fxos.cdn.mozilla.net/dictionaries/%imEngineId/%ver/%dictFilename'; + '/js/imes/%imEngineId/dictionaries/%dictFilename'; // If the data schema changes in an incompatible way, we must bump the // version-to-download here. diff --git a/gaia/apps/marketplace/marketplace.js b/gaia/apps/marketplace/marketplace.js index 84dbc0c5..b00ac828 100644 --- a/gaia/apps/marketplace/marketplace.js +++ b/gaia/apps/marketplace/marketplace.js @@ -6,6 +6,12 @@ function labels(){document.getElementById('browse').textContent=text('Ouvrir le catalogue','Open catalogue');document.getElementById('open').textContent=text('Ouvrir','Open');document.getElementById('install').textContent=text('Installer','Install');document.getElementById('cancel').textContent=text('Annuler','Cancel');} function loadFailed(){status(text('Catalogue inaccessible. Vérifiez la connexion Internet, puis réessayez.','Catalogue unavailable. Check your Internet connection, then try again.'));document.getElementById('open').hidden=true;} frame.addEventListener('mozbrowsererror',loadFailed); + frame.addEventListener('mozbrowserclose',()=>{ + if(busy)return; + status(text('Le catalogue est fermé. Vous pouvez le rouvrir ci-dessous.', + 'The catalogue is closed. You can reopen it below.')); + document.getElementById('open').hidden=true; + }); function browse(){labels();message.hidden=true;dialog.hidden=true;frame.style.visibility='visible';frame.src='https://vulpes-os.org/marketplace/?mode=app&lang='+(fr()?'fr':'en');} function status(value){frame.style.visibility='hidden';message.hidden=false;document.getElementById('status').textContent=value;} async function prepare(url){ diff --git a/gaia/apps/settings/js/modules/panel_cache.js b/gaia/apps/settings/js/modules/panel_cache.js index a5b558af..df481e13 100755 --- a/gaia/apps/settings/js/modules/panel_cache.js +++ b/gaia/apps/settings/js/modules/panel_cache.js @@ -86,7 +86,16 @@ define(function(require) { var path = pathElement ? pathElement.dataset.path : null; var panelFuncLoaded = function(panelFunc) { - var panel = panelFunc(); + var panel; + try { + panel = panelFunc(); + if (!panel || typeof panel.beforeShow !== 'function') { + throw new TypeError('Invalid settings panel: ' + panelId); + } + } catch (error) { + if (callback) callback(null, error); + return; + } _panelCache[panelId] = panel; if (callback) { callback(panel); @@ -97,6 +106,8 @@ define(function(require) { require([path], function(panelFunc) { // Create a new panel object for static panels. panelFuncLoaded(panelFunc ? panelFunc : SettingsPanel); + }, function(error) { + if (callback) callback(null, error); }); } else { panelFuncLoaded(SettingsPanel); diff --git a/gaia/apps/settings/js/modules/settings_service.js b/gaia/apps/settings/js/modules/settings_service.js index 767adbef..e9219b1b 100755 --- a/gaia/apps/settings/js/modules/settings_service.js +++ b/gaia/apps/settings/js/modules/settings_service.js @@ -187,7 +187,12 @@ define(function(require) { } _loadPanel(panelId, function() { - PanelCache.get(panelId, function(panel) { + PanelCache.get(panelId, function(panel, loadError) { + if (loadError || !panel) { + console.error('Settings panel ' + panelId + ':', loadError); + callback(loadError || new Error('Missing settings panel: ' + panelId)); + return; + } var newPanelElement = document.getElementById(panelId); var currentPanelId = _currentNavigation && _currentNavigation.panelId; diff --git a/gaia/apps/settings/js/startup.js b/gaia/apps/settings/js/startup.js index ae98e120..18478b09 100755 --- a/gaia/apps/settings/js/startup.js +++ b/gaia/apps/settings/js/startup.js @@ -408,14 +408,13 @@ var appStarter = AppStarter(); - if (document.readyState !== 'loading') { + // Deferred Gaia components contain UMD wrappers. Finish loading them before + // Alameda starts, otherwise an anonymous component can become a panel module. + if (document.readyState === 'complete') { appStarter.start(); } else { - document.addEventListener('readystatechange', function readyStateChange() { - if (document.readyState == 'interactive') { - document.removeEventListener('readystatechange', readyStateChange); - appStarter.start(); - } - }); + document.addEventListener('DOMContentLoaded', function() { + appStarter.start(); + }, { once: true }); } })(); diff --git a/host/gaia-input.js b/host/gaia-input.js index 59f6c7e3..25c0524a 100644 --- a/host/gaia-input.js +++ b/host/gaia-input.js @@ -55,8 +55,15 @@ ime.dispatchEvent(new Event('inputcontextchange')); }; Object.defineProperty(navigator,'mozInputMethod',{value:ime}); - addEventListener('vulpes-service-event',({detail})=>{if(detail.type==='input-focus')setState(detail.data);}); - call('input.request',{operation:'state'}).then(setState).catch(console.error); + let focusReceived=false; + addEventListener('vulpes-service-event',({detail})=>{ + if(detail.type==='input-focus') {focusReceived=true;setState(detail.data);} + }); + // A focus event can arrive while the initial state request is in flight. + // Its older snapshot must not clear or reopen the newer input context. + call('input.request',{operation:'state'}).then(state=>{ + if(!focusReceived)setState(state); + }).catch(console.error); window.resizeTo=(_width,height)=>call('input.request',{operation:'height',height}).catch(console.error); } if(app==='system') { diff --git a/host/gaia-theme.css b/host/gaia-theme.css index 1d91c0bf..b2432989 100644 --- a/host/gaia-theme.css +++ b/host/gaia-theme.css @@ -103,3 +103,25 @@ background-color: var(--vulpes-surface) !important; color: var(--vulpes-text) !important; } + +/* Keep the message and its surface paired, including lazily loaded Gaia dialogs. */ +:root #vulpes-gaia-notice, +:root #vulpes-gaia-notice > section, +:root #vulpes-gaia-notice > section > p { + background-color: #f4f4f4 !important; + color: #202b33 !important; +} +:root[data-vulpes-theme="dark"] #vulpes-gaia-notice, +:root[data-vulpes-theme="dark"] #vulpes-gaia-notice > section, +:root[data-vulpes-theme="dark"] #vulpes-gaia-notice > section > p { + background-color: var(--vulpes-surface) !important; + color: var(--vulpes-text) !important; +} +:root[data-vulpes-app="settings"] p[data-vulpes-unavailable] { + background-color: #f4f4f4 !important; + color: #202b33 !important; +} +:root[data-vulpes-theme="dark"][data-vulpes-app="settings"] p[data-vulpes-unavailable] { + background-color: var(--vulpes-surface) !important; + color: var(--vulpes-text) !important; +} diff --git a/overrides/keyboard.gaiamobile.org/gaia_build_defer_settings.js b/overrides/keyboard.gaiamobile.org/gaia_build_defer_settings.js new file mode 100644 index 00000000..fb7b4e10 --- /dev/null +++ b/overrides/keyboard.gaiamobile.org/gaia_build_defer_settings.js @@ -0,0 +1,966 @@ +;window.COMPONENTS_BASE_URL='/shared/elements/';;(function(exports){'use strict';exports.ComponentUtils={style:function(baseUrl){if(window.VulpesCompat){window.VulpesCompat.componentStyle(this,baseUrl).catch(console.error);return;}var style=document.createElement('style');var url=baseUrl+'style.css';var self=this;style.setAttribute('scoped','');style.innerHTML='@import url('+url+');';this.appendChild(style);this.style.visibility='hidden';style.addEventListener('load',function(){if(self.shadowRoot){self.shadowRoot.appendChild(style.cloneNode(true));} +self.style.visibility='';});}};}(window));;(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.GaiaHeader=f()}})(function(){var define,module,exports;return(function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;ospace&&fontSize!==min&&fontSize--);return{textWidth:textWidth,fontSize:fontSize,overflowing:textWidth>space};};function getTextWidth(text,font){var ctx=getCanvasContext(font);var width=ctx.measureText(text).width;debug('got text width',width);return width;} +function getCanvasContext(font){debug('get canvas context',font);var cached=cache[font];if(cached){return cached;} +var canvas=document.createElement('canvas');canvas.setAttribute('moz-opaque','true');canvas.setAttribute('width','1px');canvas.setAttribute('height','1px');debug('created canvas',canvas);var ctx=canvas.getContext('2d',{willReadFrequently:true});ctx.font=font;return cache[font]=ctx;} +function trim(text){return text.replace(/\s+/g,' ').trim();}});})(typeof define=='function'&&define.amd?define:(function(n,w){'use strict';return typeof module=='object'?function(c){c(require,exports,module);}:function(c){var m={exports:{}};c(function(n){return w[n];},m.exports,m);w[n]=m.exports;};})('font-fit',this));},{}],2:[function(require,module,exports){;(function(define){'use strict';define(function(require,exports,module){var textContent=Object.getOwnPropertyDescriptor(Node.prototype,'textContent');var innerHTML=Object.getOwnPropertyDescriptor(Element.prototype,'innerHTML');var removeAttribute=Element.prototype.removeAttribute;var setAttribute=Element.prototype.setAttribute;var noop=function(){};exports.register=function(name,props){var baseProto=getBaseProto(props.extends);var template=props.template||baseProto.templateString;var extensible=props.extensible=props.hasOwnProperty('extensible')?props.extensible:true;delete props.extends;if(template){if(extensible&&props.template){props.templateString=props.template;} +var output=processCss(template,name);props.template=document.createElement('template');props.template.innerHTML=output.template;props.lightCss=output.lightCss;props.globalCss=props.globalCss||'';props.globalCss+=output.globalCss;} +injectGlobalCss(props.globalCss);delete props.globalCss;var descriptors=mixin(props.attrs||{},base.descriptors);props._attrs=props.attrs;delete props.attrs;var proto=createProto(baseProto,props);Object.defineProperties(proto,descriptors);try{return document.registerElement(name,{prototype:proto});}catch(e){if(e.name!=='NotSupportedError'){throw e;}}};var base={properties:{GaiaComponent:true,attributeChanged:noop,attached:noop,detached:noop,created:noop,createdCallback:function(){if(this.dirObserver){addDirObserver();} +injectLightCss(this);this.created();},attributeChangedCallback:function(name,from,to){var prop=toCamelCase(name);if(this._attrs&&this._attrs[prop]){this[prop]=to;} +this.attributeChanged(name,from,to);},attachedCallback:function(){if(this.dirObserver){this.setInnerDirAttributes=setInnerDirAttributes.bind(null,this);document.addEventListener('dirchanged',this.setInnerDirAttributes);} +this.attached();},detachedCallback:function(){if(this.dirObserver){document.removeEventListener('dirchanged',this.setInnerDirAttributes);} +this.detached();},setupShadowRoot:function(){if(!this.template){return;} +var node=document.importNode(this.template.content,true);this.createShadowRoot().appendChild(node);if(this.dirObserver){setInnerDirAttributes(this);} +return this.shadowRoot;},setAttr:function(name,value){var internal=this.shadowRoot.firstElementChild;setAttribute.call(internal,name,value);setAttribute.call(this,name,value);},removeAttr:function(name){var internal=this.shadowRoot.firstElementChild;removeAttribute.call(internal,name);removeAttribute.call(this,name);}},descriptors:{textContent:{set:function(value){textContent.set.call(this,value);if(this.lightStyle){this.appendChild(this.lightStyle);}},get:function(){return textContent.get();}},innerHTML:{set:function(value){innerHTML.set.call(this,value);if(this.lightStyle){this.appendChild(this.lightStyle);}},get:innerHTML.get}}};var defaultPrototype=createProto(HTMLElement.prototype,base.properties);function getBaseProto(proto){if(!proto){return defaultPrototype;} +proto=proto.prototype||proto;return!proto.GaiaComponent?createProto(proto,base.properties):proto;} +function createProto(proto,props){return mixin(Object.create(proto),props);} +var hasShadowCSS=(function(){var div=document.createElement('div');try{div.querySelector(':host');div.querySelector('::content');return true;} +catch(e){return false;}})();var regex={shadowCss:/(?:\:host|\:\:content)[^{]*\{[^}]*\}/g,':host':/(?:\:host)/g,':host()':/\:host\((.+)\)(?: \:\:content)?/g,':host-context':/\:host-context\((.+)\)([^{,]+)?/g,'::content':/(?:\:\:content)/g};function processCss(template,name){var globalCss='';var lightCss='';if(!hasShadowCSS){template=template.replace(regex.shadowCss,function(match){var hostContext=regex[':host-context'].exec(match);if(hostContext){globalCss+=match.replace(regex['::content'],'').replace(regex[':host-context'],'$1 '+name+'$2').replace(/ +/g,' ');}else{lightCss+=match.replace(regex[':host()'],name+'$1').replace(regex[':host'],name).replace(regex['::content'],name);} +return'';});} +return{template:template,lightCss:lightCss,globalCss:globalCss};} +function injectGlobalCss(css){if(!css){return;} +var style=document.createElement('style');style.innerHTML=css.trim();headReady().then(function(){document.head.appendChild(style);});} +function headReady(){return new Promise(function(resolve){if(document.head){return resolve();} +window.addEventListener('load',function fn(){window.removeEventListener('load',fn);resolve();});});} +function injectLightCss(el){if(hasShadowCSS){return;} +var stylesheet=el.querySelector('style');if(!stylesheet){stylesheet=document.createElement('style');stylesheet.setAttribute('scoped','');stylesheet.appendChild(document.createTextNode(el.lightCss));el.appendChild(stylesheet);} +el.lightStyle=stylesheet;} +function toCamelCase(string){return string.replace(/-(.)/g,function replacer(string,p1){return p1.toUpperCase();});} +var dirObserver;function setInnerDirAttributes(component){var dir=component.dir||document.dir;Array.from(component.shadowRoot.children).forEach(element=>{if(element.nodeName!=='STYLE'){element.dir=dir;}});} +function addDirObserver(){if(dirObserver){return;} +dirObserver=new MutationObserver(onChanged);dirObserver.observe(document.documentElement,{attributeFilter:['dir'],attributes:true});function onChanged(mutations){document.dispatchEvent(new Event('dirchanged'));}} +function mixin(target,source){for(var key in source){target[key]=source[key];} +return target;}});})(typeof define=='function'&&define.amd?define:(function(n,w){'use strict';return typeof module=='object'?function(c){c(require,exports,module);}:function(c){var m={exports:{}};c(function(n){return w[n];},m.exports,m);w[n]=m.exports;};})('gaia-component',this));},{}],3:[function(require,module,exports){(function(define){'use strict';define(function(require,exports,module){var base=window.GAIA_ICONS_BASE_URL||window.COMPONENTS_BASE_URL||'bower_components/';if(!document.documentElement){window.addEventListener('load',load);}else{load();} +function load(){if(isLoaded()){return;} +var link=document.createElement('link');link.rel='stylesheet';link.type='text/css';link.href=base+'gaia-icons/gaia-icons.css';document.head.appendChild(link);exports.loaded=true;} +function isLoaded(){return exports.loaded||document.querySelector('link[href*=gaia-icons]')||document.documentElement.classList.contains('gaia-icons-loaded');}});})(typeof define=='function'&&define.amd?define:(function(n,w){'use strict';return typeof module=='object'?function(c){c(require,exports,module);}:function(c){var m={exports:{}};c(function(n){return w[n];},m.exports,m);w[n]=m.exports;};})('gaia-icons',this));},{}],4:[function(require,module,exports){;(function(define){'use strict';define(function(require,exports,module){var component=require('gaia-component');var fontFit=require('font-fit');require('gaia-icons');var debug=0?console.log.bind(console):function(){};const KNOWN_ACTIONS={menu:'menu',back:'back',close:'close'};const TITLE_FONT='italic 300 24px FiraSans';const TITLE_PADDING=10;const MINIMUM_FONT_SIZE_CENTERED=20;const MINIMUM_FONT_SIZE_UNCENTERED=16;const MAXIMUM_FONT_SIZE=23;module.exports=component.register('gaia-header',{extensible:false,dirObserver:true,created:function(){debug('created');this.setupShadowRoot();this.els={actionButton:this.shadowRoot.querySelector('.action-button'),titles:this.getElementsByTagName('h1')};this.els.actionButton.addEventListener('click',e=>this.onActionButtonClick(e));this.observer=new MutationObserver(this.onMutation.bind(this));this.ignoreDir=this.hasAttribute('ignore-dir');this.titleEnd=this.getAttribute('title-end');this.titleStart=this.getAttribute('title-start');this.noFontFit=this.getAttribute('no-font-fit');this.notFlush=this.hasAttribute('not-flush');this.action=this.getAttribute('action');this.unresolved={};this.pending={};this._resizeThrottlingId=null;this.onResize=this.onResize.bind(this);},attached:function(){debug('attached');this.runFontFitSoon();this.observerStart();window.addEventListener('resize',this.onResize);},detached:function(){debug('detached');window.removeEventListener('resize',this.onResize);this.observerStop();this.clearPending();},clearPending:function(){for(var key in this.pending){this.pending[key].clear();delete this.pending[key];} +window.cancelAnimationFrame(this._resizeThrottlingId);this._resizeThrottlingId=null;},runFontFit:function(){debug('run font-fit');if(this.noFontFit){return Promise.resolve();} +var titles=this.els.titles;var space=this.getTitleSpace();var styles=[].map.call(titles,el=>this.getTitleStyle(el,space));return this.setTitleStylesSoon(styles);},runFontFitSoon:function(){debug('run font-fit soon');if(this.pending.runFontFitSoon){return;} +this.pending.runFontFitSoon=this.nextTick(()=>{delete this.pending.runFontFitSoon;this.runFontFit();});},getTitleStyle:function(el,space){debug('get el style',el,space);var text=el.textContent;var styleId=space.start+text+space.end+'#'+space.value;if(!text||!text.trim()){return debug('exit: no text');} +if(getStyleId(el)===styleId){return debug('exit: no change');} +var marginStart=this.getTitleMarginStart();var textSpace=space.value-Math.abs(marginStart);var fontFitResult=this.fontFit(text,textSpace,{min:MINIMUM_FONT_SIZE_CENTERED});var overflowing=fontFitResult.overflowing;var padding={start:0,end:0};if(overflowing){debug('title overflowing');padding.start=!space.start?TITLE_PADDING:0;padding.end=!space.end?TITLE_PADDING:0;textSpace=space.value-padding.start-padding.end;fontFitResult=this.fontFit(text,textSpace);marginStart=0;} +return{id:styleId,fontSize:fontFitResult.fontSize,marginStart:marginStart,overflowing:overflowing,padding:padding};},setTitleStylesSoon:function(styles){debug('set title styles soon',styles);var key='setStyleTitlesSoon';this._titleStyles=styles;if(this.unresolved[key]){return this.unresolved[key];} +this.unresolved[key]=new Promise((resolve)=>{this.pending[key]=this.nextTick(()=>{var styles=this._titleStyles;var els=this.els.titles;[].forEach.call(els,(el,i)=>{if(!styles[i]){return debug('exit');} +this.setTitleStyle(el,styles[i]);});delete this._titleStyles;delete this.unresolved[key];delete this.pending[key];resolve();});});},setTitleStyle:function(el,style){debug('set title style',style);this.observerStop();if(this.ignoreDir){el.style.marginLeft=style.marginStart+'px';el.style.paddingLeft=style.padding.start+'px';el.style.paddingRight=style.padding.end+'px';}else{el.style.marginInlineStart=style.marginStart+'px';el.style.paddingInlineStart=style.padding.start+'px';el.style.paddingInlineEnd=style.padding.end+'px';} +el.style.fontSize=style.fontSize+'px';setStyleId(el,style.id);this.observerStart();},fontFit:function(text,space,opts={}){debug('font fit:',text,space,opts);var fontFitArgs={font:TITLE_FONT,min:opts.min||MINIMUM_FONT_SIZE_UNCENTERED,max:MAXIMUM_FONT_SIZE,text:text,space:space};return fontFit(fontFitArgs);},observerStart:function(){if(this.observing){return;} +this.observer.observe(this,{childList:true,attributes:true,subtree:true});this.observing=true;debug('observer started');},observerStop:function(){if(!this.observing){return;} +this.observer.disconnect();this.observing=false;debug('observer stopped');},onResize:function(e){debug('onResize',this._resizeThrottlingId);if(this._resizeThrottlingId!==null){return;} +this._resizeThrottlingId=window.requestAnimationFrame(()=>{this._resizeThrottlingId=null;this.runFontFitSoon();});},onMutation:function(mutations){debug('on mutation',mutations);if(!this.pending.runFontFitSoon){this.runFontFit();}},getTitleSpace:function(){var start=this.titleStart;var end=this.titleEnd;var space=this.getWidth()-start-end;var result={value:space,start:start,end:end};debug('get title space',result);return result;},getWidth:function(){var value=this.notFlush?this.clientWidth:window.innerWidth;debug('get width',value);return value;},triggerAction:function(){if(this.action){this.els.actionButton.click();}},onActionButtonClick:function(){debug('action button click');var config={detail:{type:this.action}};var e=new CustomEvent('action',config);setTimeout(()=>this.dispatchEvent(e));},getTitleMarginStart:function(){var start=this.titleStart;var end=this.titleEnd;var marginStart=end-start;debug('get title margin start',marginStart);return marginStart;},getButtonsBeforeTitle:function(){var children=this.children;var l=children.length;var els=[];for(var i=0;i=0;i--){var el=children[i];if(el.tagName==='H1'){break;} +if(!contributesToLayout(el)){continue;} +els.push(el);} +return els;},sumButtonWidths:function(buttons){var defaultWidth=50;var sum=buttons.reduce((prev,button)=>{var isStandardButton=button===this.els.actionButton;var width=isStandardButton?defaultWidth:button.clientWidth;return prev+width;},0);debug('sum button widths',buttons,sum);return sum;},attrs:{action:{get:function(){return this._action;},set:function(value){var action=KNOWN_ACTIONS[value];if(action===this._action){return;} +this.setAttr('action',action);this._action=action;}},titleStart:{get:function(){debug('get title-start');if('_titleStart'in this){return this._titleStart;} +var buttons=this.getButtonsBeforeTitle();var value=this.sumButtonWidths(buttons);debug('get title-start',buttons,value);return value;},set:function(value){debug('set title-start',value);value=parseInt(value,10);if(value===this._titleStart||isNaN(value)){return;} +this.setAttr('title-start',value);this._titleStart=value;debug('set');}},titleEnd:{get:function(){debug('get title-end');if('_titleEnd'in this){return this._titleEnd;} +var buttons=this.getButtonsAfterTitle();return this.sumButtonWidths(buttons);},set:function(value){debug('set title-end',value);value=parseInt(value,10);if(value===this._titleEnd||isNaN(value)){return;} +this.setAttr('title-end',value);this._titleEnd=value;}},noFontFit:{get:function(){return this._noFontFit||false;},set:function(value){debug('set no-font-fit',value);value=!!(value||value==='');if(value===this.noFontFit){return;} +this._noFontFit=value;if(value){this.setAttr('no-font-fit','');} +else{this.removeAttr('no-font-fit');}}},ignoreDir:{get:function(){return this._ignoreDir||false;},set:function(value){debug('set ignore-dir',value);value=!!(value||value==='');if(value===this.ignoreDir){return;} +this._ignoreDir=value;this.dirObserver=!value;if(value){this.setAttr('ignore-dir','');} +else{this.removeAttr('ignore-dir');}}}},template:`
+ + +
+ + `,nextTick:nextTick});function contributesToLayout(el){return el.localName!=='style'&&!el.hasAttribute('l10n-action');} +function setStyleId(el,id){el._styleId=id;} +function getStyleId(el){return el._styleId;} +function nextTick(fn){var cleared;Promise.resolve().then(()=>{if(!cleared){fn();}});return{clear:function(){cleared=true;}};}});})(typeof define=='function'&&define.amd?define:(function(n,w){'use strict';return typeof module=='object'?function(c){c(require,exports,module);}:function(c){var m={exports:{}};c(function(n){return w[n];},m.exports,m);w[n]=m.exports;};})('gaia-header',this));},{"font-fit":1,"gaia-component":2,"gaia-icons":3}]},{},[4])(4)});;;(function(define){'use strict';define(function(require,exports,module){var textContent=Object.getOwnPropertyDescriptor(Node.prototype,'textContent');var innerHTML=Object.getOwnPropertyDescriptor(Element.prototype,'innerHTML');var removeAttribute=Element.prototype.removeAttribute;var setAttribute=Element.prototype.setAttribute;var noop=function(){};exports.register=function(name,props){var baseProto=getBaseProto(props.extends);var template=props.template||baseProto.templateString;var extensible=props.extensible=props.hasOwnProperty('extensible')?props.extensible:true;delete props.extends;if(template){if(extensible&&props.template){props.templateString=props.template;} +var output=processCss(template,name);props.template=document.createElement('template');props.template.innerHTML=output.template;props.lightCss=output.lightCss;props.globalCss=props.globalCss||'';props.globalCss+=output.globalCss;} +injectGlobalCss(props.globalCss);delete props.globalCss;var descriptors=mixin(props.attrs||{},base.descriptors);props._attrs=props.attrs;delete props.attrs;var proto=createProto(baseProto,props);Object.defineProperties(proto,descriptors);try{return document.registerElement(name,{prototype:proto});}catch(e){if(e.name!=='NotSupportedError'){throw e;}}};var base={properties:{GaiaComponent:true,attributeChanged:noop,attached:noop,detached:noop,created:noop,createdCallback:function(){if(this.dirObserver){addDirObserver();} +injectLightCss(this);this.created();},attributeChangedCallback:function(name,from,to){var prop=toCamelCase(name);if(this._attrs&&this._attrs[prop]){this[prop]=to;} +this.attributeChanged(name,from,to);},attachedCallback:function(){if(this.dirObserver){this.setInnerDirAttributes=setInnerDirAttributes.bind(null,this);document.addEventListener('dirchanged',this.setInnerDirAttributes);} +this.attached();},detachedCallback:function(){if(this.dirObserver){document.removeEventListener('dirchanged',this.setInnerDirAttributes);} +this.detached();},setupShadowRoot:function(){if(!this.template){return;} +var node=document.importNode(this.template.content,true);this.createShadowRoot().appendChild(node);if(this.dirObserver){setInnerDirAttributes(this);} +return this.shadowRoot;},setAttr:function(name,value){var internal=this.shadowRoot.firstElementChild;setAttribute.call(internal,name,value);setAttribute.call(this,name,value);},removeAttr:function(name){var internal=this.shadowRoot.firstElementChild;removeAttribute.call(internal,name);removeAttribute.call(this,name);}},descriptors:{textContent:{set:function(value){textContent.set.call(this,value);if(this.lightStyle){this.appendChild(this.lightStyle);}},get:function(){return textContent.get();}},innerHTML:{set:function(value){innerHTML.set.call(this,value);if(this.lightStyle){this.appendChild(this.lightStyle);}},get:innerHTML.get}}};var defaultPrototype=createProto(HTMLElement.prototype,base.properties);function getBaseProto(proto){if(!proto){return defaultPrototype;} +proto=proto.prototype||proto;return!proto.GaiaComponent?createProto(proto,base.properties):proto;} +function createProto(proto,props){return mixin(Object.create(proto),props);} +var hasShadowCSS=(function(){var div=document.createElement('div');try{div.querySelector(':host');div.querySelector('::content');return true;} +catch(e){return false;}})();var regex={shadowCss:/(?:\:host|\:\:content)[^{]*\{[^}]*\}/g,':host':/(?:\:host)/g,':host()':/\:host\((.+)\)(?: \:\:content)?/g,':host-context':/\:host-context\((.+)\)([^{,]+)?/g,'::content':/(?:\:\:content)/g};function processCss(template,name){var globalCss='';var lightCss='';if(!hasShadowCSS){template=template.replace(regex.shadowCss,function(match){var hostContext=regex[':host-context'].exec(match);if(hostContext){globalCss+=match.replace(regex['::content'],'').replace(regex[':host-context'],'$1 '+name+'$2').replace(/ +/g,' ');}else{lightCss+=match.replace(regex[':host()'],name+'$1').replace(regex[':host'],name).replace(regex['::content'],name);} +return'';});} +return{template:template,lightCss:lightCss,globalCss:globalCss};} +function injectGlobalCss(css){if(!css){return;} +var style=document.createElement('style');style.innerHTML=css.trim();headReady().then(function(){document.head.appendChild(style);});} +function headReady(){return new Promise(function(resolve){if(document.head){return resolve();} +window.addEventListener('load',function fn(){window.removeEventListener('load',fn);resolve();});});} +function injectLightCss(el){if(hasShadowCSS){return;} +var stylesheet=el.querySelector('style');if(!stylesheet){stylesheet=document.createElement('style');stylesheet.setAttribute('scoped','');stylesheet.appendChild(document.createTextNode(el.lightCss));el.appendChild(stylesheet);} +el.lightStyle=stylesheet;} +function toCamelCase(string){return string.replace(/-(.)/g,function replacer(string,p1){return p1.toUpperCase();});} +var dirObserver;function setInnerDirAttributes(component){var dir=component.dir||document.dir;Array.from(component.shadowRoot.children).forEach(element=>{if(element.nodeName!=='STYLE'){element.dir=dir;}});} +function addDirObserver(){if(dirObserver){return;} +dirObserver=new MutationObserver(onChanged);dirObserver.observe(document.documentElement,{attributeFilter:['dir'],attributes:true});function onChanged(mutations){document.dispatchEvent(new Event('dirchanged'));}} +function mixin(target,source){for(var key in source){target[key]=source[key];} +return target;}});})(typeof define=='function'&&define.amd?define:(function(n,w){'use strict';return typeof module=='object'?function(c){c(require,exports,module);}:function(c){var m={exports:{}};c(function(n){return w[n];},m.exports,m);w[n]=m.exports;};})('gaia-component',this));;;(function(define){define(function(require,exports,module){'use strict';var component=require('gaia-component');module.exports=component.register('gaia-toast',{created:function(){this.setupShadowRoot();this.els={inner:this.shadowRoot.querySelector('.inner'),bread:this.shadowRoot.querySelector('.bread')};this.timeout=this.getAttribute('timeout');},show:function(){this.els.bread.removeEventListener('animationend',this.onAnimateOutEnd);clearTimeout(this.hideTimeout);this.els.inner.classList.add('visible');var reflow=this.els.inner.offsetTop;this.els.bread.classList.remove('animate-out');this.els.bread.classList.add('animate-in');this.hideTimeout=setTimeout(this.hide.bind(this),this.timeout);},hide:function(){var self=this;clearTimeout(this.hideTimeout);this.els.bread.classList.remove('animate-in');this.els.bread.classList.add('animate-out');this.els.bread.removeEventListener('animationend',this.onAnimateOutEnd);this.onAnimateOutEnd=function(){self.els.bread.removeEventListener('animationend',self.onAnimateOutEnd);self.els.bread.classList.remove('animate-out');self.els.inner.classList.remove('visible');};this.els.bread.addEventListener('animationend',this.onAnimateOutEnd);},animateOut:function(){},attrs:{timeout:{get:function(){return this.getAttribute('timeout')||1000;},set:function(value){var current=this.getAttribute('timeout');if(current==value){return;} +else if(!value){this.removeAttribute('timeout');} +else{this.setAttribute('timeout',value);}}}},template:` +
+
+ +
+
+ + + `,globalCss:` + @keyframes gaia-toast-enter { + 0% { + transform: translateY(100%); + opacity: 0; + } + + 40% { + opacity: 0; + } + + 100% { + transform: translateY(0%); + opacity: 1; + } + } + + @keyframes gaia-toast-leave { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } + } + `});});})(typeof define=='function'&&define.amd?define:(function(n,w){'use strict';return typeof module=='object'?function(c){c(require,exports,module);}:function(c){var m={exports:{}};c(function(n){return w[n];},m.exports,m);w[n]=m.exports;};})('gaia-toast',this));;'use strict';window.GaiaSubheader=(function(win){var proto=Object.create(HTMLElement.prototype);var baseurl=window.GaiaSubheaderBaseurl||'/shared/elements/gaia_subheader/';proto.createdCallback=function(){ComponentUtils.style.call(this,baseurl);};return document.registerElement('gaia-subheader',{prototype:proto});})(window);;'use strict';window.GaiaButtons=(function(win){var proto=Object.create(HTMLElement.prototype);var baseurl=window.GaiaButtonsBaseurl||'/shared/elements/gaia_buttons/';proto.createdCallback=function(){ComponentUtils.style.call(this,baseurl);};return document.registerElement('gaia-buttons',{prototype:proto});})(window);;'use strict';window.GaiaCheckbox=(function(win){var proto=Object.create(HTMLElement.prototype);var baseurl=window.GaiaCheckboxBaseurl||'/shared/elements/gaia_checkbox/';proto.createdCallback=function(){this.lastClick=0;this.throttleTime=250;var shadow=this.createShadowRoot();this._template=template.content.cloneNode(true);this._wrapper=this._template.getElementById('checkbox');this._wrapper.addEventListener('click',this.handleClick.bind(this));this.configureClass();this.checked=this.hasAttribute('checked');this._wrapper.setAttribute('aria-checked',this.checked);this.setAttribute('role','presentation');shadow.appendChild(this._template);ComponentUtils.style.call(this,baseurl);};proto.handleClick=function(e){e.preventDefault();e.stopImmediatePropagation();if(this.lastClick+this.throttleTime>Date.now()){return;} +this.lastClick=Date.now();var event=new MouseEvent('click',{view:window,bubbles:true,cancelable:true});this.dispatchEvent(event);if(!event.defaultPrevented){this.checked=!this.checked;this._wrapper.setAttribute('aria-checked',this.checked);} +this.dispatchEvent(new CustomEvent('change',{bubbles:true,cancelable:false}));};proto.configureClass=function(){this._wrapper.className=this.className;};proto.attributeChangedCallback=function(name,from,to){if(name==='class'){this._wrapper.className=to;}};Object.defineProperty(proto,'checked',{get:function(){return this._checked||false;},set:function(value){this._wrapper.classList.toggle('checked',value);this._checked=value;}});Object.defineProperty(proto,'name',{get:function(){return this.getAttribute('name');},set:function(value){this.setAttribute('name',value);}});var template=document.createElement('template');template.innerHTML=` + + `;return document.registerElement('gaia-checkbox',{prototype:proto});})(window);;'use strict';window.GaiaConfirm=(function(win){var proto=Object.create(HTMLElement.prototype);var baseurl=window.GaiaConfirmBaseurl||'/shared/elements/gaia_confirm/';function eatEvent(unless,event){if(unless&&unless.indexOf(event.target)!==-1){return;} +event.preventDefault();event.stopImmediatePropagation();} +proto.createdCallback=function(){var shadow=this.createShadowRoot();this._template=template.content.cloneNode(true);shadow.appendChild(this._template);ComponentUtils.style.call(this,baseurl);var confirm=this.querySelector('gaia-buttons .confirm');var cancel=this.querySelector('gaia-buttons .cancel');if(confirm){confirm.addEventListener('click',(e)=>{eatEvent(null,e);this.dispatchEvent(new CustomEvent('confirm'));});} +if(cancel){cancel.addEventListener('click',(e)=>{eatEvent(null,e);this.dispatchEvent(new CustomEvent('cancel'));});}};var template=document.createElement('template');template.innerHTML=``;return document.registerElement('gaia-confirm',{prototype:proto});})(window);;(function(window,undefined){'use strict';function L10nError(message,id,loc){this.name='L10nError';this.message=message;this.id=id;this.loc=loc;} +L10nError.prototype=Object.create(Error.prototype);L10nError.prototype.constructor=L10nError;var io={_load:function(type,url,callback,sync){var xhr=new XMLHttpRequest();var needParse;if(xhr.overrideMimeType){xhr.overrideMimeType(type);} +xhr.open('GET',url,!sync);if(type==='application/json'){if(sync){needParse=true;}else{xhr.responseType='json';}} +xhr.addEventListener('load',function io_onload(e){if(e.target.status===200||e.target.status===0){var res=e.target.response||e.target.responseText;callback(null,needParse?JSON.parse(res):res);}else{callback(new L10nError('Not found: '+url));}});xhr.addEventListener('error',callback);xhr.addEventListener('timeout',callback);try{xhr.send(null);}catch(e){if(e.name==='NS_ERROR_FILE_NOT_FOUND'){callback(new L10nError('Not found: '+url));}else{throw e;}}},load:function(url,callback,sync){return io._load('text/plain',url,callback,sync);},loadJSON:function(url,callback,sync){return io._load('application/json',url,callback,sync);}};function EventEmitter(){} +EventEmitter.prototype.emit=function ee_emit(){if(!this._listeners){return;} +var args=Array.prototype.slice.call(arguments);var type=args.shift();if(!this._listeners[type]){return;} +var typeListeners=this._listeners[type].slice();for(var i=0;i2){throw new L10nError('Error in ID: "'+name+'".'+' Nested attributes are not supported.');} +var attr;if(nameElements.length>1){name=nameElements[0];attr=nameElements[1];if(attr[0]==='$'){throw new L10nError('Attribute can\'t start with "$"',id);}}else{attr=null;} +this.setEntityValue(name,attr,key,this.unescapeString(value),ast);},setEntityValue:function(id,attr,key,rawValue,ast){var pos,v;var value=rawValue.indexOf('{{')>-1?this.parseString(rawValue):rawValue;if(attr){pos=this.entryIds[id];if(pos===undefined){v={$i:id};if(key){v[attr]={};v[attr][key]=value;}else{v[attr]=value;} +ast.push(v);this.entryIds[id]=ast.length-1;return;} +if(key){if(typeof(ast[pos][attr])==='string'){ast[pos][attr]={$x:this.parseIndex(ast[pos][attr]),$v:{}};} +ast[pos][attr].$v[key]=value;return;} +ast[pos][attr]=value;return;} +if(key){pos=this.entryIds[id];if(pos===undefined){v={};v[key]=value;ast.push({$i:id,$v:v});this.entryIds[id]=ast.length-1;return;} +if(typeof(ast[pos].$v)==='string'){ast[pos].$x=this.parseIndex(ast[pos].$v);ast[pos].$v={};} +ast[pos].$v[key]=value;return;} +ast.push({$i:id,$v:value});this.entryIds[id]=ast.length-1;},parseString:function(str){var chunks=str.split(this.patterns.placeables);var complexStr=[];var len=chunks.length;var placeablesCount=(len-1)/2;if(placeablesCount>=MAX_PLACEABLES){throw new L10nError('Too many placeables ('+placeablesCount+', max allowed is '+MAX_PLACEABLES+')');} +for(var i=0;i-1){return[{},env['__'+id]];} +if(args&&args.hasOwnProperty(id)){if(typeof args[id]==='string'||(typeof args[id]==='number'&&!isNaN(args[id]))){return[{},args[id]];}else{throw new L10nError('Arg must be a string or a number: '+id);}} +if(id in env&&id!=='__proto__'){return format(args,env[id]);} +throw new L10nError('Unknown reference: '+id);} +function subPlaceable(locals,args,env,id){var res;try{res=resolveIdentifier(args,env,id);}catch(err){return[{error:err},'{{ '+id+' }}'];} +var value=res[1];if(typeof value==='number'){return res;} +if(typeof value==='string'){if(value.length>=MAX_PLACEABLE_LENGTH){throw new L10nError('Too many characters in placeable ('+ +value.length+', max allowed is '+ +MAX_PLACEABLE_LENGTH+')');} +if(locals.contextIsNonLatin1||value.match(nonLatin1)){res[1]=FSI+value+PDI;} +return res;} +return[{},'{{ '+id+' }}'];} +function interpolate(locals,args,env,arr){return arr.reduce(function(prev,cur){if(typeof cur==='string'){return[prev[0],prev[1]+cur];}else if(cur.t==='idOrVar'){var placeable=subPlaceable(locals,args,env,cur.v);return[prev[0],prev[1]+placeable[1]];}},[locals,'']);} +function resolveSelector(args,env,expr,index){var selectorName=index[0].v;var selector=resolveIdentifier(args,env,selectorName)[1];if(typeof selector!=='function'){return selector;} +var argValue=index[1]?resolveIdentifier(args,env,index[1])[1]:undefined;if(selector===env.__plural){if(argValue===0&&'zero'in expr){return'zero';} +if(argValue===1&&'one'in expr){return'one';} +if(argValue===2&&'two'in expr){return'two';}} +return selector(argValue);} +function resolveValue(locals,args,env,expr,index){if(!expr){return[locals,expr];} +if(typeof expr==='string'||typeof expr==='boolean'||typeof expr==='number'){return[locals,expr];} +if(Array.isArray(expr)){locals.contextIsNonLatin1=expr.some(function($_){return typeof($_)==='string'&&$_.match(nonLatin1);});return interpolate(locals,args,env,expr);} +if(index){var selector=resolveSelector(args,env,expr,index);if(expr.hasOwnProperty(selector)){return resolveValue(locals,args,env,expr[selector]);}} +if('other'in expr){return resolveValue(locals,args,env,expr.other);} +throw new L10nError('Unresolvable value');} +var Resolver={createEntry:createEntry,format:format,rePlaceables:rePlaceables};function walkContent(node,fn){if(typeof node==='string'){return fn(node);} +if(node.t==='idOrVar'){return node;} +var rv=Array.isArray(node)?[]:{};var keys=Object.keys(node);for(var i=0,key;(key=keys[i]);i++){if(key==='$i'||key==='$x'){rv[key]=node[key];}else{rv[key]=walkContent(node[key],fn);}} +return rv;} +var reAlphas=/[a-zA-Z]/g;var reVowels=/[aeiouAEIOU]/g;var ACCENTED_MAP='\u0226\u0181\u0187\u1E12\u1E16\u0191\u0193\u0126\u012A'+'\u0134\u0136\u013F\u1E3E\u0220\u01FE\u01A4\u024A\u0158'+'\u015E\u0166\u016C\u1E7C\u1E86\u1E8A\u1E8E\u1E90'+'[\\]^_`'+'\u0227\u0180\u0188\u1E13\u1E17\u0192\u0260\u0127\u012B'+'\u0135\u0137\u0140\u1E3F\u019E\u01FF\u01A5\u024B\u0159'+'\u015F\u0167\u016D\u1E7D\u1E87\u1E8B\u1E8F\u1E91';var FLIPPED_MAP='\u2200\u0510\u2183p\u018E\u025F\u05E4HI\u017F'+'\u04FC\u02E5WNO\u0500\xD2\u1D1AS\u22A5\u2229\u0245'+'\uFF2DX\u028EZ'+'[\\]\u1D65_,'+'\u0250q\u0254p\u01DD\u025F\u0183\u0265\u0131\u027E'+'\u029E\u0285\u026Fuodb\u0279s\u0287n\u028C\u028Dx\u028Ez';function makeLonger(val){return val.replace(reVowels,function(match){return match+match.toLowerCase();});} +function replaceChars(map,val){return val.replace(reAlphas,function(match){return map.charAt(match.charCodeAt(0)-65);});} +var reWords=/[^\W0-9_]+/g;function makeRTL(val){return val.replace(reWords,function(match){return'\u202e'+match+'\u202c';});} +var reExcluded=/(%[EO]?\w|\{\s*.+?\s*\}|&[#\w]+;|<\s*.+?\s*>)/;function mapContent(fn,val){if(!val){return val;} +var parts=val.split(reExcluded);var modified=parts.map(function(part){if(reExcluded.test(part)){return part;} +return fn(part);});return modified.join('');} +function Pseudo(id,name,charMap,modFn){this.id=id;this.translate=mapContent.bind(null,function(val){return replaceChars(charMap,modFn(val));});this.name=this.translate(name);} +var PSEUDO={'fr-x-psaccent':new Pseudo('fr-x-psaccent','Runtime Accented',ACCENTED_MAP,makeLonger),'ar-x-psbidi':new Pseudo('ar-x-psbidi','Runtime Bidi',FLIPPED_MAP,makeRTL)};function Locale(id,ctx){this.id=id;this.ctx=ctx;this.isReady=false;this.entries=Object.create(null);this.entries.__plural=getPluralRule(this.isPseudo()?this.ctx.defaultLocale:id);} +Locale.prototype.isPseudo=function(){return this.ctx.qps.indexOf(this.id)!==-1;};var bindingsIO={extra:function(id,ver,path,type,callback,errback){if(type==='properties'){type='text';} +navigator.mozApps.getLocalizationResource(id,ver,path,type).then(callback.bind(null,null),errback);},app:function(id,ver,path,type,callback,errback,sync){switch(type){case'properties':io.load(path,callback,sync);break;case'json':io.loadJSON(path,callback,sync);break;}},};Locale.prototype.build=function L_build(callback){var sync=!callback;var ctx=this.ctx;var self=this;var l10nLoads=ctx.resLinks.length;function onL10nLoaded(err){if(err){ctx._emitter.emit('fetcherror',err);} +if(--l10nLoads<=0){self.isReady=true;if(callback){callback();}}} +if(l10nLoads===0){onL10nLoaded();return;} +function onJSONLoaded(err,json){if(!err&&json){self.addAST(json);} +onL10nLoaded(err);} +function onPropLoaded(err,source){if(!err&&source){var ast=PropertiesParser.parse(ctx,source);self.addAST(ast);} +onL10nLoaded(err);} +var idToFetch=this.isPseudo()?ctx.defaultLocale:this.id;var appVersion=null;var source='app';if(typeof(navigator)!=='undefined'){source=navigator.mozL10n._config.localeSources[this.id]||'app';appVersion=navigator.mozL10n._config.appVersion;} +for(var i=0;i=0)?'rtl':'ltr';} +var readyStates={loading:0,interactive:1,complete:2};function whenInteractive(callback){if(readyStates[document.readyState]>=readyStates.interactive){callback();return;} +document.addEventListener('readystatechange',function l10n_onrsc(){if(readyStates[document.readyState]>=readyStates.interactive){document.removeEventListener('readystatechange',l10n_onrsc);callback();}});} +function initObserver(){nodeObserver=new MutationObserver(onMutations.bind(navigator.mozL10n));nodeObserver.observe(document,moConfig);} +function init(pretranslate){if(!pretranslate){initObserver();} +initResources.call(navigator.mozL10n);} +function initResources(){var meta={};var nodes=document.head.querySelectorAll('link[rel="localization"],'+'meta[name="availableLanguages"],'+'meta[name="defaultLanguage"],'+'meta[name="appVersion"],'+'script[type="application/l10n"]');for(var i=0,node;node=nodes[i];i++){var type=node.getAttribute('rel')||node.nodeName.toLowerCase();switch(type){case'localization':this.ctx.resLinks.push(node.getAttribute('href'));break;case'meta':onMetaInjected.call(this,node,meta);break;case'script':onScriptInjected.call(this,node);break;}} +var additionalLanguagesPromise;if(navigator.mozApps&&navigator.mozApps.getAdditionalLanguages){additionalLanguagesPromise=navigator.mozApps.getAdditionalLanguages().catch(function(e){console.error('Error while loading getAdditionalLanguages',e);});document.addEventListener('additionallanguageschange',function(evt){registerLocales.call(this,meta,evt.detail);this.ctx.requestLocales.apply(this.ctx,navigator.languages||[navigator.language]);}.bind(this));}else{additionalLanguagesPromise=Promise.resolve();} +additionalLanguagesPromise.then(function(extraLangs){registerLocales.call(this,meta,extraLangs);initLocale.call(this);}.bind(this));} +function registerLocales(meta,extraLangs){var locales=buildLocaleList.call(this,meta,extraLangs);navigator.mozL10n._config.localeSources=locales[1];this.ctx.registerLocales(locales[0],Object.keys(locales[1]));} +function getMatchingLangpack(appVersion,langpacks){for(var i=0,langpack;(langpack=langpacks[i]);i++){if(langpack.target===appVersion){return langpack;}} +return null;} +function buildLocaleList(meta,extraLangs){var loc,lp;var localeSources=Object.create(null);var defaultLocale=meta.defaultLanguage||this.ctx.defaultLocale;if(meta.availableLanguages){for(loc in meta.availableLanguages){localeSources[loc]='app';}} +if(extraLangs){for(loc in extraLangs){lp=getMatchingLangpack(this._config.appVersion,extraLangs[loc]);if(!lp){continue;} +if(!(loc in localeSources)||!meta.availableLanguages[loc]||parseInt(lp.revision)>meta.availableLanguages[loc]){localeSources[loc]='extra';}}} +if(!(defaultLocale in localeSources)){localeSources[defaultLocale]='app';} +return[defaultLocale,localeSources];} +function splitAvailableLanguagesString(str){var langs={};str.split(',').forEach(function(lang){lang=lang.trim().split(':');langs[lang[0]]=parseInt(lang[1]);});return langs;} +function onMetaInjected(node,meta){switch(node.getAttribute('name')){case'availableLanguages':meta.availableLanguages=splitAvailableLanguagesString(node.getAttribute('content'));break;case'defaultLanguage':meta.defaultLanguage=node.getAttribute('content');break;case'appVersion':navigator.mozL10n._config.appVersion=node.getAttribute('content');break;}} +function onScriptInjected(node){var lang=node.getAttribute('lang');var locale=this.ctx.getLocale(lang);locale.addAST(JSON.parse(node.textContent));} +function initLocale(){this.ctx.requestLocales.apply(this.ctx,navigator.languages||[navigator.language]);window.addEventListener('languagechange',function l10n_langchange(){this.ctx.requestLocales.apply(this.ctx,navigator.languages||[navigator.language]);}.bind(this));} +function localizeMutations(mutations){var mutation;var targets=new Set();for(var i=0;i]/g;var htmlEntities={'&':'&','<':'<','>':'>',};function translateDocument(){document.documentElement.lang=this.language.code;document.documentElement.dir=this.language.direction;translateFragment.call(this,document.documentElement);} +function translateFragment(element){if(typeof element.hasAttribute==='function'&&element.hasAttribute('data-l10n-id')){translateElement.call(this,element);} +var nodes=getTranslatableChildren(element);for(var i=0;ithis.childViews[name].beforeShow(options)));};BaseView.prototype.show=function(){return Promise.all(Object.keys(this.childViews).map(name=>this.childViews[name].show()));};BaseView.prototype.beforeHide=function(){return Promise.all(Object.keys(this.childViews).map(name=>this.childViews[name].beforeHide()));};BaseView.prototype.hide=function(){return Promise.all(Object.keys(this.childViews).map(name=>this.childViews[name].hide()));};exports.BaseView=BaseView;})(window);;'use strict';(function(exports){var UserDictionary=function UserDictionary(){this._wordSet=null;this._saveQueue=null;this._dbStore=null;this._started=false;};UserDictionary.prototype.start=function(){if(this._started){console.error('UserDictionary: attempting to start twice');return;} +this._started=true;this._saveQueue=Promise.resolve();this._dbStore=new PromiseStorage(this.DB_NAME);this._dbStore.start();};UserDictionary.prototype.stop=function(){if(this._started){this._wordSet=null;this._saveQueue=null;this._dbStore.stop();}};UserDictionary.prototype.DB_NAME='UserDictLatin';UserDictionary.prototype.getList=function(){if(!this._started){return Promise.reject('UserDictionary not started');} +return this._dbStore.getItem('wordlist').then(list=>{list=list||[];this._wordSet=new Set(list);return this._wordSet;}).catch(e=>{e&&console.error(e);this._wordSet=new Set([]);return this._wordSet;});};UserDictionary.prototype._saveDict=function(){var wordList=Array.from(this._wordSet);wordList=wordList.sort((a,b)=>a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()));this._wordSet=new Set(wordList);var dictBlob=0===wordList.length?undefined:new WordListConverter(wordList).toBlob();var p=this._saveQueue.then(()=>{this._dbStore.setItems({'wordlist':wordList,'dictblob':dictBlob});return wordList;});this._saveQueue=p;this._saveQueue=this._saveQueue.catch(e=>{console.error(e);});return p;};UserDictionary.prototype.addWord=function(word){if(!this._started){return Promise.reject('UserDictionary not started');} +word=word.trim();if(this._wordSet.has(word)){return Promise.reject('existing');} +this._wordSet.add(word);return this._saveDict();};UserDictionary.prototype.updateWord=function(oldWord,word){if(!this._started){return Promise.reject('UserDictionary not started');} +word=word.trim();if(oldWord===word){return Promise.resolve(Array.from(this._wordSet));} +this._wordSet.delete(oldWord);if(this._wordSet.has(word)){return this._saveDict().then(()=>Promise.reject('existing'));}else{this._wordSet.add(word);return this._saveDict();}};UserDictionary.prototype.removeWord=function(word){if(!this._started){return Promise.reject('UserDictionary not started');} +this._wordSet.delete(word);return this._saveDict();};UserDictionary.prototype._getBlob=function(){return this._dbStore.getItem('dictblob');};exports.UserDictionary=UserDictionary;})(window);;'use strict';(function(){var namespace;try{namespace=window;}catch(e){namespace=module.exports;} +(function(exports){const _EndOfWord=String.fromCharCode(0);const _DEBUG=true;var TSTNode=function(ch){this.ch=ch;this.left=this.center=this.right=null;this.frequency=0;this.count=0;};var TSTTree=function(ch){this.table={};};TSTTree.prototype.insert=function(node,word,freq){var ch=word[0];if(!node){node=new TSTNode(ch);} +if(chnode.ch){node.right=this.insert(node.right,word,freq);}else{node.frequency=Math.max(node.frequency,freq);if(word.length>1){node.center=this.insert(node.center,word.substring(1),freq);}} +return node;};TSTTree.prototype.setCount=function(node){if(!node){return 0;} +node.count=this.setCount(node.left)+this.setCount(node.right)+1;this.setCount(node.center);return node.count;};TSTTree.prototype.rotateRight=function(node){var tmp=node.left;node.left=tmp.right;tmp.right=node;node.count=(node.left?node.left.count:0)+ +(node.right?node.right.count:0)+1;tmp.count=(tmp.left?tmp.left.count:0)+tmp.right.count+1;return tmp;};TSTTree.prototype.rotateLeft=function(node){var tmp=node.right;node.right=tmp.left;tmp.left=node;node.count=(node.left?node.left.count:0)+ +(node.right?node.right.count:0)+1;tmp.count=tmp.left.count+(tmp.right?tmp.right.count:0)+1;return tmp;};TSTTree.prototype.divide=function(node,divCount){var leftCount=node.left?node.left.count:0;if(divCountleftCount){node.right=this.divide(node.right,divCount-leftCount-1);node=this.rotateLeft(node);} +return node;};TSTTree.prototype.balanceLevel=function(node){if(!node){return node;} +node=this.divide(node,Math.floor(node.count/2));node.left=this.balanceLevel(node.left);node.right=this.balanceLevel(node.right);node.center=this.balanceTree(node.center);return node;};TSTTree.prototype.collectLevel=function(level,node){if(!node){return;} +level.push(node);this.collectLevel(level,node.left);this.collectLevel(level,node.right);};TSTTree.prototype.sortLevelByFreq=function(node){var nodes=[];this.collectLevel(nodes,node);nodes.sort(function(node1,node2){return node1.ch.charCodeAt(0)-node2.ch.charCodeAt(0);});nodes.sort(function(node1,node2){return node2.frequency-node1.frequency;});var prev=null;nodes.forEach(function(node,index){node.next=(indexroot.ch){root.right=this.promoteNodeToRoot(root.right,node);return this.rotateLeft(root);}else{return root;}};TSTTree.prototype.balanceTree=function(node){if(!node){return;} +node=this.promoteNodeToRoot(node,this.sortLevelByFreq(node));node.left=this.balanceLevel(node.left);node.right=this.balanceLevel(node.right);node.center=this.balanceTree(node.center);return node;};TSTTree.prototype.balance=function(root){this.setCount(root);root=this.balanceTree(root);return root;};var TSTBuilder=function(words){this.words=words;this._built=false;this.maxWordLength=0;this.characterFrequency={};this.tstRoot=null;};TSTBuilder.prototype.debug=function(msg){if(_DEBUG){console.log(msg);}};TSTBuilder.prototype.build=function(){var tstRoot=null;var tree=new TSTTree();this.words.forEach(function(wordFreq){var word=wordFreq.w;var freq=wordFreq.f;this.maxWordLength=Math.max(this.maxWordLength,word.length);tstRoot=tree.insert(tstRoot,word+_EndOfWord,freq);word.split('').forEach(function(ch){if(ch in this.characterFrequency){this.characterFrequency[ch]++;}else{this.characterFrequency[ch]=1;}},this);},this);tstRoot=tree.balance(tstRoot);this.tstRoot=tstRoot;this._built=true;};TSTBuilder.prototype.getTreeRoot=function(){if(!this._built){throw Error('TST not built yet.');} +return this.tstRoot;};TSTBuilder.prototype.getMaxWordLength=function(){if(!this._built){throw Error('TST not built yet.');} +return this.maxWordLength;};TSTBuilder.prototype.getCharacterFrequency=function(){if(!this._built){throw Error('TST not built yet.');} +return this.characterFrequency;};var TSTSerializer=function(tstRoot){this._tstRoot=tstRoot;this._output=null;};TSTSerializer.prototype.debug=function(msg){if(_DEBUG){console.log(msg);}};TSTSerializer.prototype._serializeNode=function(node){this._output.push(node);if(node.ch==_EndOfWord&&node.center){this.debug('nul node with a center!');} +if(node.ch!=_EndOfWord&&!node.center){this.debug('char node with no center!');} +if(node.center){this._serializeNode(node.center);} +if(node.left){this._serializeNode(node.left);} +if(node.right){this._serializeNode(node.right);}};TSTSerializer.prototype.serializeToNodes=function(){this._output=[];this._serializeNode(this._tstRoot);return this._output;};var TSTBlobBuilder=function(nodes,characterFrequency,maxWordLength){this._nodes=nodes;this._characterFrequency=characterFrequency;this._maxWordLength=maxWordLength;this._output=null;this._outputPos=undefined;};TSTBlobBuilder.prototype.debug=function(msg){if(_DEBUG){console.log(msg);}};TSTBlobBuilder.prototype.toBlobArray=function(){var nodeslen=this._computeOffsets();this._output=new Uint8Array(15+(6*Object.keys(this._characterFrequency).length)+ +nodeslen);this._outputPos=0;this._output[this._outputPos++]='F'.charCodeAt(0);this._output[this._outputPos++]='x'.charCodeAt(0);this._output[this._outputPos++]='O'.charCodeAt(0);this._output[this._outputPos++]='S'.charCodeAt(0);this._output[this._outputPos++]='D'.charCodeAt(0);this._output[this._outputPos++]='I'.charCodeAt(0);this._output[this._outputPos++]='C'.charCodeAt(0);this._output[this._outputPos++]='T'.charCodeAt(0);this._output[this._outputPos++]=0;this._output[this._outputPos++]=0;this._output[this._outputPos++]=0;this._output[this._outputPos++]=1;this._output[this._outputPos++]=Math.min(this._maxWordLength,255);var characters=Object.keys(this._characterFrequency).map(function(ch){return{ch:ch,freq:this._characterFrequency[ch]};},this);characters.sort(function(chFreq1,chFreq2){return chFreq2.freq-chFreq1.freq;});this._output[this._outputPos++]=(characters.length>>8)&0xFF;this._output[this._outputPos++]=characters.length&0xFF;characters.forEach(function(chFreq){var charCode=chFreq.ch.charCodeAt(0);this._output[this._outputPos++]=(charCode>>8)&0xFF;this._output[this._outputPos++]=charCode&0xFF;var freq=chFreq.freq;this._output[this._outputPos++]=(freq>>24)&0xFF;this._output[this._outputPos++]=(freq>>16)&0xFF;this._output[this._outputPos++]=(freq>>8)&0xFF;this._output[this._outputPos++]=freq&0xFF;},this);this._nodes.forEach(function(node){this._emitNode(node);},this);return this._output.buffer;};TSTBlobBuilder.prototype._computeOffsets=function(){var offset=0;this._nodes.forEach(function(node){node.offset=offset;var charlen;if(node.ch==_EndOfWord){charlen=0;}else if(node.ch.charCodeAt(0)<=255){charlen=1;}else{charlen=2;} +var nextlen=node.next?3:0;offset=offset+1+charlen+nextlen;});return offset;};TSTBlobBuilder.prototype._writeUint24=function(x){this._output[this._outputPos++]=(x>>16)&0xFF;this._output[this._outputPos++]=(x>>8)&0xFF;this._output[this._outputPos++]=x&0xFF;};TSTBlobBuilder.prototype._emitNode=function(node){var charcode=(node.ch==_EndOfWord)?0:node.ch.charCodeAt(0);var cbit=(0!==charcode)?0x80:0;var sbit=(charcode>255)?0x40:0;var nbit=node.next?0x20:0;var freq;if(0===node.frequency){freq=0;}else{freq=1+Math.floor(node.frequency*31);} +var firstbyte=cbit|sbit|nbit|(freq&0x1F);this._output[this._outputPos++]=firstbyte;if(cbit){if(sbit){this._output[this._outputPos++]=charcode>>8;} +this._output[this._outputPos++]=charcode&0xFF;} +if(nbit){this._writeUint24(node.next.offset);}};var WordListConverter=function(words){this.blob=undefined;words.reduce(function(prevType,word){var thisType=typeof word;if(thisType!==prevType){throw'Type mismatch. previous: '+prevType+', this: '+thisType;} +if('object'===thisType){if(!('w'in word)){throw'"w" field not found in word';} +if(!('f'in word)){throw'"f" field not found in word';} +if(!(word.f>=0&&word.f<1)){throw'"f" value not in allowed range';}} +return thisType;},typeof words[0]);this.words=words;};WordListConverter.prototype.debug=function(msg){if(_DEBUG){console.log(msg);}};WordListConverter.prototype.toBlob=function(){if(this.blob){return this.blob;} +var words=this.words;if('string'===typeof words[0]){words=words.map(function(word){return{w:word,f:0.3};});} +var tstBuilder=new TSTBuilder(words);tstBuilder.build();var nodes=new TSTSerializer(tstBuilder.getTreeRoot()).serializeToNodes();this.blob=new TSTBlobBuilder(nodes,tstBuilder.getCharacterFrequency(),tstBuilder.getMaxWordLength()).toBlobArray();return this.blob;};exports.WordListConverter=WordListConverter;})(namespace);})();;'use strict';(function(exports){var UserDictionaryListPanel=function(app){BaseView.apply(this);this.app=app;this._model=new UserDictionary(this);this._populated=false;this._listContainer=null;this._domWordMap=null;this._wordDomMap=null;};UserDictionaryListPanel.prototype=Object.create(BaseView.prototype);UserDictionaryListPanel.prototype.CONTAINER_ID='panel-ud-wordlist';UserDictionaryListPanel.prototype.start=function(){BaseView.prototype.start.call(this);this._listContainer=this.container.querySelector('#ud-wordlist-list');this._model.start();this._domWordMap=new WeakMap();this._wordDomMap={};};UserDictionaryListPanel.prototype.stop=function(){BaseView.prototype.stop.call(this);this._populated=false;this._listContainer=null;this._model.stop();this._domWordMap=null;this._wordDomMap=null;};UserDictionaryListPanel.prototype.beforeShow=function(options){if(!this._populated){this._populated=true;return this._model.getList().then(words=>{if(!words||words.size===0){this.container.classList.add('empty');}else{this.container.classList.remove('empty');this._rearrangeList(words);}}).catch(e=>console.error(e));}};UserDictionaryListPanel.prototype.show=function(){this.container.querySelector('#ud-addword-btn').addEventListener('click',this);this._listContainer.addEventListener('click',this);this.container.querySelector('gaia-header').addEventListener('action',this);};UserDictionaryListPanel.prototype.beforeHide=function(){this.container.querySelector('#ud-addword-btn').removeEventListener('click',this);this._listContainer.removeEventListener('click',this);this.container.querySelector('gaia-header').removeEventListener('action',this);};UserDictionaryListPanel.prototype.handleEvent=function(evt){var target=evt.target;switch(evt.type){case'click':if('ud-addword-btn'===target.id){this._showAddDialog();}else if('li'===target.tagName.toLowerCase()){this._showEditDialog(target);evt.preventDefault();} +break;case'action':this.app.panelController.navigateToRoot();break;}};UserDictionaryListPanel.prototype._rearrangeList=function(wordList){wordList.forEach(word=>{word=word.trim();var wordElem;if(word in this._wordDomMap){wordElem=this._wordDomMap[word];}else{wordElem=this._createWordElem(word);} +this._listContainer.appendChild(wordElem);});};UserDictionaryListPanel.prototype._createWordElem=function(word){var innerAnchor=document.createElement('a');innerAnchor.href='#'+word;innerAnchor.textContent=word;var elem=document.createElement('li');elem.appendChild(innerAnchor);this._domWordMap.set(elem,word);this._wordDomMap[word]=elem;return elem;};UserDictionaryListPanel.prototype._showAddDialog=function(){this.app.dialogController.openDialog(this.app.dialogController.userDictionaryEditDialog).then(result=>{if('commit'===result.action){this._addWord(result.word);}}).catch(e=>e&&console.error(e));};UserDictionaryListPanel.prototype._showEditDialog=function(wordElem){var word=this._domWordMap.get(wordElem);this.app.dialogController.openDialog(this.app.dialogController.userDictionaryEditDialog,{word:word}).then(result=>{switch(result.action){case'remove':this._removeWord(word,wordElem);break;case'commit':this._replaceWord(word,result.word,wordElem);break;}}).catch(e=>e&&console.error(e));};UserDictionaryListPanel.prototype._addWord=function(word){word=word.trim();if(word.length>0){var awakeLock=this.app.closeLockManager.requestLock('stayAwake');this._model.addWord(word).then(wordList=>{awakeLock.unlock();this.container.classList.remove('empty');this._rearrangeList(wordList);}).catch(e=>{awakeLock.unlock();if('existing'===e){return;}else{console.error(e);}});}};UserDictionaryListPanel.prototype._removeWord=function(word,wordElem){var awakeLock=this.app.closeLockManager.requestLock('stayAwake');this._model.removeWord(word).then(()=>{awakeLock.unlock();this._domWordMap.delete(wordElem);delete this._wordDomMap[word];this._listContainer.removeChild(wordElem);if(0===this._listContainer.childNodes.length){this.container.classList.add('empty');}}).catch(e=>{awakeLock.unlock();console.error(e);});};UserDictionaryListPanel.prototype._replaceWord=function(oldWord,newWord,wordElem){newWord=newWord.trim();if(newWord.length>0&&oldWord!==newWord){var awakeLock=this.app.closeLockManager.requestLock('stayAwake');this._model.updateWord(oldWord,newWord).then(wordList=>{awakeLock.unlock();this._domWordMap.set(wordElem,newWord);delete this._wordDomMap[oldWord];this._wordDomMap[newWord]=wordElem;wordElem.childNodes[0].textContent=newWord;this._rearrangeList(wordList);}).catch(e=>{awakeLock.unlock();if('existing'===e){this._domWordMap.delete(wordElem);delete this._wordDomMap[oldWord];this._listContainer.removeChild(wordElem);}else{console.error(e);}});}};exports.UserDictionaryListPanel=UserDictionaryListPanel;})(window);;'use strict';(function(exports){var UserDictionaryEditDialog=function(){BaseView.apply(this);this._inputField=null;this._oldWord=undefined;};UserDictionaryEditDialog.prototype=Object.create(BaseView.prototype);UserDictionaryEditDialog.prototype.CONTAINER_ID='panel-ud-editword';UserDictionaryEditDialog.prototype.onsubmit=undefined;UserDictionaryEditDialog.prototype.start=function(){BaseView.prototype.start.call(this);this._inputField=this.container.querySelector('#ud-editword-input');};UserDictionaryEditDialog.prototype.stop=function(){BaseView.prototype.stop.call(this);this._inputField=null;};UserDictionaryEditDialog.prototype.beforeShow=function(options){if(options&&'word'in options){this.container.classList.remove('add-mode');this._inputField.value=options.word;this._oldWord=options.word;}else{this.container.classList.add('add-mode');}};UserDictionaryEditDialog.prototype.show=function(){this.container.querySelector('#ud-editword-header').addEventListener('action',this);this.container.querySelector('#ud-saveword-btn').addEventListener('click',this);this.container.querySelector('#ud-editword-input').addEventListener('keydown',this);this.container.querySelector('#ud-editword-delete-btn').addEventListener('click',this);this.container.querySelector('#ud-editword-dialog-cancel-btn').addEventListener('click',this);this.container.querySelector('#ud-editword-dialog-delete-btn').addEventListener('click',this);this._inputField.focus();};UserDictionaryEditDialog.prototype.beforeHide=function(){this.container.querySelector('#ud-editword-header').removeEventListener('action',this);this.container.querySelector('#ud-saveword-btn').removeEventListener('click',this);this.container.querySelector('#ud-editword-input').removeEventListener('keydown',this);this.container.querySelector('#ud-editword-delete-btn').removeEventListener('click',this);this.container.querySelector('#ud-editword-dialog-cancel-btn').removeEventListener('click',this);this.container.querySelector('#ud-editword-dialog-delete-btn').removeEventListener('click',this);};UserDictionaryEditDialog.prototype.hide=function(){this._inputField.value='';this._oldWord=undefined;};UserDictionaryEditDialog.prototype.handleEvent=function(evt){switch(evt.type){case'action':this._cancel();break;case'click':switch(evt.target.id){case'ud-saveword-btn':this._commitWord();break;case'ud-editword-delete-btn':navigator.mozL10n.setAttributes(this.container.querySelector('#ud-editword-delete-prompt'),'userDictionaryDeletePrompt',{word:this._oldWord});this.container.querySelector('#ud-editword-delete-dialog').removeAttribute('hidden');break;case'ud-editword-dialog-delete-btn':this._removeWord();case'ud-editword-dialog-cancel-btn':this.container.querySelector('#ud-editword-delete-dialog').setAttribute('hidden',true);break;} +break;case'keydown':if(evt.keyCode===KeyEvent.DOM_VK_RETURN){this._commitWord();this._inputField.blur();} +break;}};UserDictionaryEditDialog.prototype._removeWord=function(){this.onsubmit({action:'remove'});};UserDictionaryEditDialog.prototype._commitWord=function(){this.onsubmit({action:'commit',word:this._inputField.value.trim()});};UserDictionaryEditDialog.prototype._cancel=function(){this.onsubmit({action:'cancel'});};exports.UserDictionaryEditDialog=UserDictionaryEditDialog;})(window);;'use strict';(function(exports){var SettingsView=function(app,container,settingsConstructor){BaseView.apply(this);this.app=app;this.container=container;this.SettingsConstructor=settingsConstructor;this.settings=null;this.elements=[];this.taskQueue=null;};SettingsView.prototype=Object.create(BaseView.prototype);SettingsView.prototype.start=function(){this.settings=new this.SettingsConstructor();this.settings.promiseManager=this.app.settingsPromiseManager;this.settings.onsettingchange=function(){this.taskQueue=this.taskQueue.then(this._updateUI.bind(this)).catch(function(e){console.error(e);});}.bind(this);this.taskQueue=this.settings.initSettings().then(this._updateUI.bind(this)).catch(function(e){console.error(e);});this.settings.KEYS.forEach(function(settingKey,i){var el=this.container.querySelector('[data-setting="'+settingKey+'"]');if(!el){return;} +this.elements[i]=el;el.addEventListener('change',this);},this);};SettingsView.prototype._updateUI=function(){var values=this.settings.getSettingsSync();this.settings.KEYS.forEach(function(settingKey,i){var el=this.elements[i];if(!el){return;} +el.disabled=false;switch(el.type||el.nodeName.toLowerCase()){case'gaia-checkbox':el.checked=values[this.settings.PROPERTIES[i]];break;case'range':el.value=values[this.settings.PROPERTIES[i]];break;default:throw'SettingsView: UI type unimplemented.';}},this);};SettingsView.prototype.handleEvent=function(evt){var el=evt.target;var settingsKey=el.dataset.setting;var lock;var value;switch(el.type||el.nodeName.toLowerCase()){case'gaia-checkbox':lock=this.app.closeLockManager.requestLock('stayAwake');value=el.checked;break;case'range':lock=this.app.closeLockManager.requestLock('stayAwake');value=el.valueAsNumber;break;default:throw'SettingsView: UI type unimplemented.';} +el.disabled=true;this.taskQueue=this.taskQueue.then(function(){return this.app.settingsPromiseManager.set(settingsKey,value);}.bind(this)).then(function(){el.disabled=false;lock.unlock();},function(e){console.error('SettingsView: Attempt to set setting failed',settingsKey,e);el.disabled=false;this._updateUI();lock.unlock();}.bind(this)).catch(function(e){console.error(e);});};SettingsView.prototype.stop=function(){this.elements.forEach(function(el){if(!el){return;} +el.removeEventListener('change',this);});this.settings=null;this.elements=[];this.taskQueue=null;};exports.SettingsView=SettingsView;})(window);;'use strict';(function(exports){var GeneralSettingsGroupView=function GeneralSettingsGroupView(app){BaseView.apply(this);this.app=app;};GeneralSettingsGroupView.prototype=Object.create(BaseView.prototype);GeneralSettingsGroupView.prototype.CONTAINER_ID='general-settings';GeneralSettingsGroupView.prototype.start=function(){BaseView.prototype.start.call(this);this.childViews.soundFeedbackSettings=new SettingsView(this.app,this.container,SoundFeedbackSettings);this.childViews.soundFeedbackSettings.start();this.childViews.vibrationFeedbackSettings=new SettingsView(this.app,this.container,VibrationFeedbackSettings);this.childViews.vibrationFeedbackSettings.start();this.childViews.imEngineSettings=new SettingsView(this.app,this.container,IMEngineSettings);this.childViews.imEngineSettings.start();};GeneralSettingsGroupView.prototype.stop=function(){BaseView.prototype.stop.call(this);this.childViews.soundFeedbackSettings.stop();delete this.childViews.soundFeedbackSettings;this.childViews.vibrationFeedbackSettings.stop();delete this.childViews.vibrationFeedbackSettings;this.childViews.imEngineSettings.stop();delete this.childViews.imEngineSettings;};exports.GeneralSettingsGroupView=GeneralSettingsGroupView;})(window);;'use strict';(function(exports){var GeneralPanel=function(app){BaseView.apply(this);this._menuUDItem=null;this.app=app;};GeneralPanel.prototype=Object.create(BaseView.prototype);GeneralPanel.prototype.CONTAINER_ID='general';GeneralPanel.prototype.USER_DICT_ITEM_ID='menu-userdict';GeneralPanel.prototype.start=function(){BaseView.prototype.start.call(this);this._menuUDItem=document.getElementById(this.USER_DICT_ITEM_ID);this.childViews.general=new GeneralSettingsGroupView(this.app);this.childViews.general.start();if(typeof HandwritingSettingsGroupView==='function'){this.childViews.handwriting=new HandwritingSettingsGroupView(this.app);this.childViews.handwriting.start();}else{this.childViews.handwriting=new BaseView();this.childViews.handwriting.start();} +this.childViews.layoutItemList=new LayoutItemListView(this.app);this.childViews.layoutItemList.start();};GeneralPanel.prototype.stop=function(){BaseView.prototype.stop.call(this);this._menuUDItem=null;this.childViews.general.stop();delete this.childViews.general;this.childViews.handwriting.stop();delete this.childViews.handwriting;this.childViews.layoutItemList.stop();delete this.childViews.layoutItemList;};GeneralPanel.prototype.show=function(){this.container.querySelector('gaia-header').addEventListener('action',this);if(this._menuUDItem){this._menuUDItem.addEventListener('click',this);} +return BaseView.prototype.show.call(this);};GeneralPanel.prototype.beforeHide=function(){this.container.querySelector('gaia-header').removeEventListener('action',this);if(this._menuUDItem){this._menuUDItem.removeEventListener('click',this);} +return BaseView.prototype.beforeHide.call(this);};GeneralPanel.prototype.handleEvent=function(evt){switch(evt.type){case'action':this.app.requestClose();break;case'click':this.app.panelController.navigateToPanel(this.app.panelController.userDictionaryListPanel);evt.preventDefault();break;}};exports.GeneralPanel=GeneralPanel;})(window);;'use strict';(function(exports){const TRANSITION_TIMEOUT=600;var PanelController=function(app,rootPanelClass){this.RootPanelClass=rootPanelClass||this.ROOT_PANEL_CLASS;this._currentPanel=null;this.app=app;this.rootPanel=null;this.userDictionaryListPanel=null;};PanelController.prototype.ROOT_PANEL_CLASS=GeneralPanel;PanelController.prototype.start=function(){this.rootPanel=new this.RootPanelClass(this.app);this.rootPanel.start();if(typeof UserDictionaryListPanel==='function'){this.userDictionaryListPanel=new UserDictionaryListPanel(this.app);this.userDictionaryListPanel.start();} +Promise.resolve(this.rootPanel.beforeShow()).then(this.rootPanel.show.bind(this.rootPanel)).catch(e=>e&&console.error(e));};PanelController.prototype.stop=function(){this._currentPanel=null;this.rootPanel.stop();this.rootPanel=null;if(this.userDictionaryListPanel){this.userDictionaryListPanel.stop();this.userDictionaryListPanel=null;}};PanelController.prototype._createTransitionPromise=function(target){return new Promise(function(resolve){var transitionEnd=function(){clearTimeout(timeout);target.removeEventListener('transitionend',transitionEnd);resolve();};var timeout=setTimeout(transitionEnd,TRANSITION_TIMEOUT);target.addEventListener('transitionend',transitionEnd);});};PanelController.prototype.navigateToRoot=function(){Promise.resolve(this._currentPanel.beforeHide()).then(()=>this.rootPanel.beforeShow()).then(()=>{var transitionPromise=this._createTransitionPromise(this._currentPanel.container);this._currentPanel.container.classList.remove('current');this.rootPanel.container.classList.remove('prev');this.rootPanel.container.classList.add('current');return transitionPromise;}).then(this._currentPanel.hide.bind(this._currentPanel)).then(this.rootPanel.show.bind(this.rootPanel)).then(()=>{this._currentPanel=null;}).catch(e=>e&&console.error(e));};PanelController.prototype.navigateToPanel=function(panel,options){this._currentPanel=panel;Promise.resolve(this.rootPanel.beforeHide()).then(()=>panel.beforeShow(options)).then(()=>{var transitionPromise=this._createTransitionPromise(panel.container);panel.container.classList.add('current');this.rootPanel.container.classList.remove('current');this.rootPanel.container.classList.add('prev');return transitionPromise;}).then(this.rootPanel.hide.bind(this.rootPanel)).then(panel.show.bind(panel)).catch(e=>e&&console.error(e));};var DialogController=function(){this.userDictionaryEditDialog=null;};DialogController.prototype.start=function(){if(typeof UserDictionaryEditDialog==='function'){this.userDictionaryEditDialog=new UserDictionaryEditDialog(this);this.userDictionaryEditDialog.start();}};DialogController.prototype.stop=function(){if(this.userDictionaryEditDialog){this.userDictionaryEditDialog.stop();this.userDictionaryEditDialog=null;}};DialogController.prototype._createTransitionPromise=PanelController.prototype._createTransitionPromise;DialogController.prototype.openDialog=function(dialog,options){if(!('onsubmit'in dialog)){return Promise.reject('Dialog does not have a onsubmit callback');} +var resultPromiseResolve,resultPromiseReject;var resultPromise=new Promise(function(resolve,reject){resultPromiseResolve=resolve;resultPromiseReject=reject;});dialog.onsubmit=results=>{resultPromiseResolve(results);Promise.resolve(dialog.beforeHide()).then(()=>{var transitionPromise=this._createTransitionPromise(dialog.container);dialog.container.classList.remove('displayed');return transitionPromise;}).then(()=>{dialog.onsubmit=undefined;return dialog.hide();}).catch(e=>e&&console.log(e));};Promise.resolve(dialog.beforeShow(options)).then(()=>{var transitionPromise=this._createTransitionPromise(dialog.container);dialog.container.classList.add('displayed');return transitionPromise;}).then(dialog.show.bind(dialog)).catch(e=>resultPromiseReject(e));return resultPromise;};exports.PanelController=PanelController;exports.DialogController=DialogController;})(window);;'use strict';'use strict'; + +(function(exports) { + +var LayoutDictionaryDownloader = function(imEngineId, dictFilename) { + this.imEngineId = imEngineId; + this.dictFilename = dictFilename; + + this._xhr = null; +}; + +// Dictionaries ship with Vulpes so adding a layout also works offline. +LayoutDictionaryDownloader.prototype.REMOTE_URL = + '/js/imes/%imEngineId/dictionaries/%dictFilename'; + +// If the data schema changes in an incompatible way, we must bump the +// version-to-download here. +LayoutDictionaryDownloader.prototype.ENGINE_DATA_VERSIONS = Object.freeze({ + 'latin': 1 +}); + +LayoutDictionaryDownloader.prototype.onprogress = null; + +LayoutDictionaryDownloader.prototype.load = function() { + if (this._xhr) { + throw new Error('LayoutDictionaryDownloader: Already downloading...'); + } + + var p = new Promise(function(resolve, reject) { + var url = this.REMOTE_URL + .replace('%imEngineId', this.imEngineId) + .replace('%ver', this.ENGINE_DATA_VERSIONS[this.imEngineId]) + .replace('%dictFilename', this.dictFilename); + + var xhr = this._xhr = new XMLHttpRequest(); + xhr.open('GET', url, true); + xhr.responseType = 'arraybuffer'; + + xhr.onprogress = function(evt) { + if (typeof this.onprogress === 'function') { + this.onprogress(evt.loaded, evt.total); + } + }.bind(this); + + xhr.onloadend = function() { + this._xhr = null; + + var data = xhr.response; + // We will only resolve the promise when the data is verified. + // For any other situration, we simply return the HTTP status code as + // a rejected placeholder value. + // (it doesn't really matter if you got 200/404/500 + // or anything if it's not what you are looking for right :-/?) + if (data && this._verifyData(data)) { + if (typeof this.onprogress === 'function') { + this.onprogress(data.byteLength, data.byteLength); + } + + resolve(data); + } else { + reject(xhr.statusText); + } + }.bind(this); + + xhr.send(); + }.bind(this)); + + return p; +}; + +LayoutDictionaryDownloader.prototype.abort = function() { + if (!this._xhr) { + throw new Error('LayoutDictionaryDownloader: ' + + 'Not downloading but abort is called.'); + } + + this._xhr.abort(); + + this._xhr = null; +}; + +LayoutDictionaryDownloader.prototype._verifyData = function(buffer) { + switch (this.imEngineId) { + case 'latin': + // There is only first 8 bytes to check -- we can't be sure if the data + // is truncated. However that shouldn't happen since our CDN sends out + // Content-Length header. + // + // TODO: Harden the verification here in some way. + // + if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < 8) { + return false; + } + var view = new Uint8Array(buffer, 0, 8); + return (String.fromCharCode.apply(String, view) === 'FxOSDICT'); + + default: + throw new Error('LayoutDictionaryDownloader: ' + + 'No verify method available for this imEngine.'); + } +}; + +exports.LayoutDictionaryDownloader = LayoutDictionaryDownloader; + +}(window)); + +;'use strict';(function(exports){var Deferred=function(){this.promise=new Promise(function(resolve,reject){this.resolve=resolve;this.reject=reject;}.bind(this));return this;};var LayoutDictionary=function(list,dict){this.dbStore=list.dbStore;this.imEngineId=dict.imEngineId;this.filename=dict.filename;this.filePath=dict.filePath;this.fileSize=dict.fileSize;this.databaseId=dict.databaseId;this.installedLayoutIds=dict.installedLayoutIds;if(dict.preloaded){this.state=this.STATE_PRELOADED;}else if(this.installedLayoutIds.size!==0){this.state=this.STATE_INSTALLED;}else{this.state=this.STATE_INSTALLABLE;} +this._downloader=null;this._installingLayoutDeferreds=null;this._installingPromise=null;this._removingPromise=null;};LayoutDictionary.prototype.state=0;LayoutDictionary.prototype.imEngineId='';LayoutDictionary.prototype.filename='';LayoutDictionary.prototype.filePath='';LayoutDictionary.prototype.fileSize=0;LayoutDictionary.prototype.databaseId=0;LayoutDictionary.prototype.installedLayoutIds=null;LayoutDictionary.prototype.downloadLoadedSize=0;LayoutDictionary.prototype.downloadTotalSize=0;LayoutDictionary.prototype.STATE_PRELOADED=1;LayoutDictionary.prototype.STATE_INSTALLABLE=2;LayoutDictionary.prototype.STATE_INSTALLING_CANCELLABLE=3;LayoutDictionary.prototype.STATE_INSTALLING=4;LayoutDictionary.prototype.STATE_INSTALLED=5;LayoutDictionary.prototype.STATE_REMOVING=6;LayoutDictionary.prototype.start=function(){this._installingLayoutDeferreds=new Map();};LayoutDictionary.prototype.stop=function(){this._downloader=null;this._installingLayoutDeferreds=null;this._installingPromise=null;this._removingPromise=null;};LayoutDictionary.prototype.installForLayout=function(layoutItem){var deferred;switch(this.state){case this.STATE_PRELOADED:throw new Error('LayoutDictionary: '+'Can\'t install a dictionary under this state.');case this.STATE_INSTALLABLE:this._startInstall();deferred=new Deferred();this._installingLayoutDeferreds.set(layoutItem,deferred);return deferred.promise;case this.STATE_INSTALLING_CANCELLABLE:case this.STATE_INSTALLING:if(this._installingLayoutDeferreds.has(layoutItem)){return this._installingLayoutDeferreds.get(layoutItem).promise;} +deferred=new Deferred();this._installingLayoutDeferreds.set(layoutItem,deferred);return deferred.promise;case this.STATE_INSTALLED:this.installedLayoutIds.add(layoutItem.id);return Promise.resolve();case this.STATE_REMOVING:return this._removingPromise.then(this.installForLayout.bind(this,layoutItem),this.installForLayout.bind(this,layoutItem));default:throw new Error('LayoutDictionary: Invalid state.');}};LayoutDictionary.prototype.removeForLayout=function(layoutItem){switch(this.state){case this.STATE_PRELOADED:throw new Error('LayoutDictionary: '+'Can\'t remove a dictionary under this state.');case this.STATE_INSTALLABLE:return Promise.resolve();case this.STATE_INSTALLING_CANCELLABLE:if(!this._installingLayoutDeferreds.has(layoutItem)){return Promise.resolve();} +var deferred=this._installingLayoutDeferreds.get(layoutItem);deferred.reject('Cancelled');this._installingLayoutDeferreds.delete(layoutItem);if(this._installingLayoutDeferreds.size>0){return deferred.promise.catch(function(){});} +this._downloader.abort();return this._installingPromise.catch(function(){});case this.STATE_INSTALLING:if(!this._installingLayoutDeferreds.has(layoutItem)){return Promise.resolve();} +deferred=this._installingLayoutDeferreds.get(layoutItem);deferred.reject('Cancelled');this._installingLayoutDeferreds.delete(layoutItem);if(this._installingLayoutDeferreds.size>0){return deferred.promise.catch(function(){});} +return this._installingPromise.catch(function(){});case this.STATE_INSTALLED:if(!this.installedLayoutIds.has(layoutItem.id)){return Promise.resolve();} +if(this.installedLayoutIds.size>1){this.installedLayoutIds.delete(layoutItem.id);return Promise.resolve();} +this._startRemove();this._removingPromise=this._removingPromise.then(function(){this.installedLayoutIds.delete(layoutItem.id);}.bind(this));return this._removingPromise;case this.STATE_REMOVING:if(this.installedLayoutIds.has(layoutItem.id)){return this._removingPromise;} +return Promise.resolve();default:throw new Error('LayoutDictionary: Invalid state.');}};LayoutDictionary.prototype._startInstall=function(){if(this.state!==this.STATE_INSTALLABLE){throw new Error('LayoutDictionary: '+'Can\'t start install a dictionary under this state.');} +this.state=this.STATE_INSTALLING_CANCELLABLE;this.downloadLoadedSize=0;this.downloadTotalSize=this.fileSize;this._downloader=new LayoutDictionaryDownloader(this.imEngineId,this.filename);this._downloader.onprogress=this._updateProgress.bind(this);this._installingPromise=this._downloader.load().then(function(data){this._downloader=null;this.state=this.STATE_INSTALLING;return this.dbStore.setItem(this.databaseId,data);}.bind(this)).then(function(){this._installingPromise=null;this._installingLayoutDeferreds.forEach(function(deferred,layoutItem){this.installedLayoutIds.add(layoutItem.id);deferred.resolve();},this);this._installingLayoutDeferreds.clear();this.state=this.STATE_INSTALLED;this.downloadLoadedSize=0;this.downloadTotalSize=0;}.bind(this)).catch(function(e){console.warn('LayoutDictionary: Installation failed with error:',e);this._downloader=null;this._installingPromise=null;this._installingLayoutDeferreds.forEach(function(deferred){deferred.reject('Failed');},this);this._installingLayoutDeferreds.clear();this.state=this.STATE_INSTALLABLE;this.downloadLoadedSize=0;this.downloadTotalSize=0;}.bind(this)).then(function(){if(this.state===this.STATE_INSTALLED&&this._installingLayoutDeferreds.size===0&&this.installedLayoutIds.size===0){return this._startRemove();}}.bind(this));};LayoutDictionary.prototype._startRemove=function(){if(this.state!==this.STATE_INSTALLED){throw new Error('LayoutDictionary: '+'Can\'t start remove a dictionary under this state.');} +this.state=this.STATE_REMOVING;this._removingPromise=this.dbStore.deleteItem(this.databaseId).then(function(){this._removingPromise=null;this.state=this.STATE_INSTALLABLE;}.bind(this)).catch(function(e){this._removingPromise=null;this.state=this.STATE_INSTALLED;throw e;}.bind(this));};LayoutDictionary.prototype._updateProgress=function(loaded,total){this.downloadLoadedSize=loaded;this.downloadTotalSize=total;Array.from(this._installingLayoutDeferreds).map(function(keyValue){return keyValue[0];}).forEach(function(layoutItem){layoutItem.updateInstallProgress(loaded,total);});};exports.LayoutDictionary=LayoutDictionary;}(window));;'use strict';(function(exports){var LayoutDictionaryList=function LayoutDictionaryList(layoutList){this.dbStore=null;this.dictionaries=null;};LayoutDictionaryList.prototype.DATABASE_NAME='imEngineData';LayoutDictionaryList.prototype.start=function(){this.dictionaries=new Map();this.dbStore=new PromiseStorage(this.DATABASE_NAME);this.dbStore.start();};LayoutDictionaryList.prototype.stop=function(){this.dictionaries.forEach(function(dict){dict.stop();});this.dictionaries=null;this.dbStore.stop();this.dbStore=null;};LayoutDictionaryList.prototype.getDictionary=function(imEngineId,dictFilePath){var databaseId=imEngineId+'/'+dictFilePath;return this.dictionaries.get(databaseId);};LayoutDictionaryList.prototype.createDictionariesFromLayouts=function createDictionariesFromLayouts(layouts){var dictsMap=this._getDictsMapFromLayouts(layouts);dictsMap.forEach(function(dict,databaseId){var layoutDict=new LayoutDictionary(this,dict);layoutDict.start();this.dictionaries.set(databaseId,layoutDict);},this);};LayoutDictionaryList.prototype._getDictsMapFromLayouts=function(layouts){var dictsMap=new Map();layouts.forEach(function(layout){if(!layout.dictFilename){return;} +var dict;var databaseId=layout.imEngineId+'/'+layout.dictFilePath;if(dictsMap.has(databaseId)){if(layout.installed){dict=dictsMap.get(databaseId);dict.installedLayoutIds.add(layout.id);} +return;} +dict={imEngineId:layout.imEngineId,filename:layout.dictFilename,filePath:layout.dictFilePath,fileSize:layout.dictFileSize,databaseId:databaseId,preloaded:layout.preloaded,installedLayoutIds:new Set()};if(layout.installed){dict.installedLayoutIds.add(layout.id);} +dictsMap.set(databaseId,dict);});return dictsMap;};exports.LayoutDictionaryList=LayoutDictionaryList;}(window));;'use strict';(function(exports){var LayoutItemErrorInfo=function(layoutItemState){switch(layoutItemState){case LayoutItem.prototype.STATE_INSTALLING_CANCELLABLE:this.error=this.ERROR_DOWNLOADERROR;break;case LayoutItem.prototype.STATE_INSTALLING:this.error=this.ERROR_INSTALLERROR;break;case LayoutItem.prototype.STATE_REMOVING:this.error=this.ERROR_REMOVEERROR;break;default:throw new Error('LayoutItemErrorInfo: Error at invalid state.');}};LayoutItemErrorInfo.prototype.ERROR_DOWNLOADERROR=1;LayoutItemErrorInfo.prototype.ERROR_INSTALLERROR=2;LayoutItemErrorInfo.prototype.ERROR_REMOVEERROR=3;var LayoutItem=function(list,layout){this.list=list;this.id=layout.id;this.name=layout.name;this.nameL10nId=layout.nameL10nId;this.imEngineId=layout.imEngineId;this.dictFilePath=layout.dictFilePath;this.fileSize=layout.dictFileSize;this.types=layout.types;if(layout.preloaded){this.state=this.STATE_PRELOADED;}else if(layout.installed){this.state=this.STATE_INSTALLED;}else{this.state=this.STATE_INSTALLABLE;} +this._dictionary=null;this._userCancelled=undefined;};LayoutItem.prototype.STATE_PRELOADED=1;LayoutItem.prototype.STATE_INSTALLABLE=2;LayoutItem.prototype.STATE_INSTALLING_CANCELLABLE=3;LayoutItem.prototype.STATE_INSTALLING=4;LayoutItem.prototype.STATE_INSTALLED=5;LayoutItem.prototype.STATE_REMOVING=6;LayoutItem.prototype.onerror=null;LayoutItem.prototype.onstatechange=null;LayoutItem.prototype.onprogress=null;LayoutItem.prototype.oninstall=null;LayoutItem.prototype.id='';LayoutItem.prototype.name='';LayoutItem.prototype.nameL10nId='';LayoutItem.prototype.types=null;LayoutItem.prototype.fileSize=0;LayoutItem.prototype.downloadLoadedSize=0;LayoutItem.prototype.downloadTotalSize=0;LayoutItem.prototype.start=function(){if(this.state!==this.STATE_PRELOADED){this._dictionary=this.list.dictionaryList.getDictionary(this.imEngineId,this.dictFilePath);}};LayoutItem.prototype.stop=function(){};LayoutItem.prototype.install=function(){if(this.state!==this.STATE_INSTALLABLE){throw new Error('LayoutItem: '+'Can\'t install a layout under this state.');} +var openLock=this.list.closeLockManager.requestLock('stayAwake');this._userCancelled=false;var p=Promise.resolve().then(function(){var p=this._dictionary.installForLayout(this);if(this._dictionary.state===this._dictionary.STATE_INSTALLING_CANCELLABLE){this.downloadLoadedSize=this._dictionary.downloadLoadedSize;this.downloadTotalSize=this._dictionary.downloadTotalSize;this._changeState(this.STATE_INSTALLING_CANCELLABLE);}else{this._changeState(this.STATE_INSTALLING);} +return p;}.bind(this)).then(function(){if(this.state!==this.STATE_INSTALLING){this.downloadLoadedSize=0;this.downloadTotalSize=0;this._changeState(this.STATE_INSTALLING);} +return navigator.mozInputMethod.addInput(this.id,{launch_path:'/index.html#'+this.id,name:this.name,description:this.name,types:this.types});}.bind(this)).then(this.list.setLayoutAsInstalled.bind(this.list,this.id)).then(function(){this._userCancelled=undefined;openLock.unlock();this._changeState(this.STATE_INSTALLED);if(typeof this.oninstall==='function'){this.oninstall();}}.bind(this)).catch(function(e){if(!this._userCancelled){this._triggerError();} +this._userCancelled=undefined;this.downloadLoadedSize=0;this.downloadTotalSize=0;openLock.unlock();this._changeState(this.STATE_INSTALLABLE);throw(e||new Error('LayoutItem: Installation failed/cancelled.'));}.bind(this));return p;};LayoutItem.prototype.cancelInstall=function(){if(this.state!==this.STATE_INSTALLING_CANCELLABLE){throw new Error('LayoutItem: '+'Can\'t cancel an install under this state.');} +this._userCancelled=true;this._dictionary.removeForLayout(this);};LayoutItem.prototype.remove=function(){if(this.state!==this.STATE_INSTALLED){throw new Error('LayoutItem: '+'Can\'t remove a layout under this state.');} +var openLock=this.list.closeLockManager.requestLock('stayAwake');var p=Promise.resolve().then(function(){this._changeState(this.STATE_REMOVING);return navigator.mozInputMethod.removeInput(this.id);}.bind(this)).then(function(){return this.list.setLayoutAsUninstalled(this.id).catch(function(e){console.error('LayoutItem: Ignoring set uninstall failure.',e);});}.bind(this)).then(function(){return this._dictionary.removeForLayout(this).catch(function(e){console.error('LayoutItem: Ignoring dictionary removal failure.',e);});}.bind(this)).then(openLock.unlock.bind(openLock)).then(this._changeState.bind(this,this.STATE_INSTALLABLE)).catch(function(e){this._triggerError();openLock.unlock();this._changeState(this.STATE_INSTALLED);throw(e||new Error('LayoutItem: Removing failed.'));}.bind(this));return p;};LayoutItem.prototype.updateInstallProgress=function(loaded,total){if(this.state!==this.STATE_INSTALLING_CANCELLABLE){console.warn('LayoutItem: Ignoring updateInstallProgress call in the wrong state.');return;} +this.downloadLoadedSize=loaded;this.downloadTotalSize=total;if(typeof this.onprogress==='function'){return this.onprogress(loaded,total);}};LayoutItem.prototype._triggerError=function(){if(typeof this.onerror==='function'){this.onerror(new LayoutItemErrorInfo(this.state));}};LayoutItem.prototype._changeState=function(state){this.state=state;if(typeof this.onstatechange==='function'){return this.onstatechange();}};exports.LayoutItemErrorInfo=LayoutItemErrorInfo;exports.LayoutItem=LayoutItem;}(window));;'use strict';(function(exports){var DownloadPreference=function(app){this.app=app;};DownloadPreference.prototype.STATE_PROMPT=0;DownloadPreference.prototype.STATE_ALLOW=1;DownloadPreference.prototype.STATE_DENY=2;DownloadPreference.prototype.PREF_DOWNLOAD_ON_DATA_CONNTECION='download.prompt-on-data-connection';DownloadPreference.prototype.start=function(){};DownloadPreference.prototype.stop=function(){};DownloadPreference.prototype.getCurrentState=function(){if(!this._isUsingDataConnection()){return Promise.resolve(this.STATE_ALLOW);} +return this.app.preferencesStore.getItem(this.PREF_DOWNLOAD_ON_DATA_CONNTECION).then(function(val){switch(val){case undefined:return this.STATE_PROMPT;case true:return this.STATE_ALLOW;case false:return this.STATE_DENY;default:console.error('DownloadPreference: Unknown preference.',val);return this.STATE_PROMPT;}}.bind(this),function(e){e&&console.error(e);return this.STATE_PROMPT;}.bind(this));};DownloadPreference.prototype.setDataConnectionDownloadState=function(state){switch(state){case this.STATE_PROMPT:return this.app.preferencesStore.deleteItem(this.PREF_DOWNLOAD_ON_DATA_CONNTECION);case this.STATE_ALLOW:return this.app.preferencesStore.setItem(this.PREF_DOWNLOAD_ON_DATA_CONNTECION,true);case this.STATE_DENY:return this.app.preferencesStore.setItem(this.PREF_DOWNLOAD_ON_DATA_CONNTECION,false);default:throw new Error('DownloadPreference: Unknown state.');}};DownloadPreference.prototype._isUsingDataConnection=function(){if(!navigator.mozMobileConnections){console.warn('DownloadPreference: mozMobileConnections is not available. '+'Assuming no data charges.');return false;} +var mobileDataConnected=Array.prototype.some.call(navigator.mozMobileConnections,function(conn){return(conn.data&&conn.data.connected);});return mobileDataConnected;};var LayoutEnabler=function(app){this.app=app;this.appManifestURL='';};LayoutEnabler.prototype.SETTING_ENABLE_LAYOUT_KEY='keyboard.enabled-layouts';LayoutEnabler.prototype.start=function(){this.appManifestURL=location.protocol+'//'+location.hostname+'/manifest.webapp';};LayoutEnabler.prototype.stop=function(){this.appManifestURL=null;};LayoutEnabler.prototype.enableLayout=function(id){return this.app.settingsPromiseManager.updateOne(this.SETTING_ENABLE_LAYOUT_KEY,function(obj){if(!obj[this.appManifestURL]){obj[this.appManifestURL]={};} +obj[this.appManifestURL][id]=true;return obj;}.bind(this));};LayoutEnabler.prototype.disableLayout=function(id){return this.app.settingsPromiseManager.updateOne(this.SETTING_ENABLE_LAYOUT_KEY,function(obj){if(!obj[this.appManifestURL]){obj[this.appManifestURL]={};} +delete obj[this.appManifestURL][id];return obj;}.bind(this));};var LayoutItemList=function(app){this.app=app;this.closeLockManager=app.closeLockManager;this.dictionaryList=null;this.downloadPreference=null;this.layoutEnabler=null;this._layoutConfigQueue=null;this._installedLayoutListSet=null;this.layoutItems=null;};LayoutItemList.prototype.ENABLED_LAYOUT_KEY='layout.dynamic-installed';LayoutItemList.prototype.CONFIG_FILE_PATH='./js/settings/layouts.json';LayoutItemList.prototype.onready=null;LayoutItemList.prototype.start=function(){this.dictionaryList=new LayoutDictionaryList(this);this.dictionaryList.start();this.downloadPreference=new DownloadPreference(this.app);this.downloadPreference.start();this.layoutEnabler=new LayoutEnabler(this.app);this.layoutEnabler.start();this.layoutItems=new Map();var p=this._getConfig().then(this._createLayoutItemsFromLayouts.bind(this)).then(function(){if(typeof this.onready==='function'){this.onready();}}.bind(this));this._layoutConfigQueue=p.catch(function(e){e&&console.error(e);});return p;};LayoutItemList.prototype.stop=function(){this.layoutItems.forEach(function(layoutItem){layoutItem.stop();});this.dictionaryList.stop();this.dictionaryList=null;this.downloadPreference.stop();this.downloadPreference=null;this.layoutEnabler.stop();this.layoutEnabler=null;this._layoutConfigQueue=null;this._installedLayoutListSet=null;};LayoutItemList.prototype.setLayoutAsInstalled=function(layoutId){var p=this._layoutConfigQueue.then(function(){this._installedLayoutListSet.add(layoutId);return this.app.preferencesStore.setItem(this.ENABLED_LAYOUT_KEY,Array.from(this._installedLayoutListSet));}.bind(this)).catch(function(e){this._installedLayoutListSet.delete(layoutId);throw e;});this._layoutConfigQueue=p.catch(function(e){e&&console.error(e);});return p;};LayoutItemList.prototype.setLayoutAsUninstalled=function(layoutId){var p=this._layoutConfigQueue.then(function(){this._installedLayoutListSet.delete(layoutId);return this.app.preferencesStore.setItem(this.ENABLED_LAYOUT_KEY,Array.from(this._installedLayoutListSet));}.bind(this)).catch(function(e){this._installedLayoutListSet.add(layoutId);throw e;});this._layoutConfigQueue=p.catch(function(e){e&&console.error(e);});return p;};LayoutItemList.prototype._getConfig=function(){var xhrPromise=new Promise(function(resolve,reject){var xhr=new XMLHttpRequest();xhr.open('GET',this.CONFIG_FILE_PATH);xhr.responseType='json';xhr.onload=function(){if(xhr.response){resolve(xhr.response);}else{reject();}};xhr.onerror=function(){reject();};xhr.send();}.bind(this));var installedLayoutsPromise=this.app.preferencesStore.getItem(this.ENABLED_LAYOUT_KEY);var p=Promise.all([xhrPromise,installedLayoutsPromise]).then(function(values){var layouts=values[0];var installedLayoutListSet=this._installedLayoutListSet=new Set(values[1]||[]);layouts.forEach(function(layout){layout.installed=layout.preloaded||installedLayoutListSet.has(layout.id);});return layouts;}.bind(this));return p;};LayoutItemList.prototype._createLayoutItemsFromLayouts=function(layouts){var needDownload=layouts.some(function(layout){return(layout.preloaded===false);});if(!needDownload){return;} +this.dictionaryList.createDictionariesFromLayouts(layouts);layouts.forEach(function(layout){var layoutItem=new LayoutItem(this,layout);layoutItem.start();this.layoutItems.set(layout.id,layoutItem);},this);};exports.LayoutItemList=LayoutItemList;exports.DownloadPreference=DownloadPreference;exports.LayoutEnabler=LayoutEnabler;}(window));;'use strict';(function(exports){var LayoutItemView=function(list,layoutItem){BaseView.apply(this);this._statusEl=null;this._progressEl=null;if(typeof layoutItem!=='object'){throw new Error('LayoutItemView: '+'LayoutItemView does not create a LayoutItem for itself, '+'please pass an approate instance as the model.');} +this.list=list;this._model=layoutItem;};LayoutItemView.prototype=Object.create(BaseView.prototype);LayoutItemView.prototype.oninlistchange=null;LayoutItemView.prototype.inList=undefined;LayoutItemView.prototype.IN_LIST_INSTALLED=0;LayoutItemView.prototype.IN_LIST_INSTALLABLE=1;LayoutItemView.prototype.TEMPLATE_ID='installable-keyboard-list-item';LayoutItemView.prototype.start=function(){this._model.onerror=this._showError.bind(this);this._model.onprogress=this._updateProgress.bind(this);this._model.onstatechange=this._updateUI.bind(this);this._model.oninstall=this._showEnableDialog.bind(this);var template=document.getElementById(this.TEMPLATE_ID);var el=this.container=document.importNode(template.content,true).firstElementChild;el.querySelector('.label').textContent=this._model.name;if(this._model.nameL10nId){el.querySelector('.label').dataset.l10nId=this._model.nameL10nId;} +el.addEventListener('click',this);this._statusEl=el.querySelector('.status');this._progressEl=el.querySelector('.progress');this._updateUI();};LayoutItemView.prototype._updateProgress=function(){var loadedSizeData=this._getHumanFileSize(this._model.downloadLoadedSize);var totalSizeData=this._getHumanFileSize(this._model.downloadTotalSize);this._progressEl.value=this._model.downloadLoadedSize;this._progressEl.max=this._model.downloadTotalSize;this._statusEl.dataset.l10nArgs=JSON.stringify({loadedSize:loadedSizeData.size,loadedSizeUnit:loadedSizeData.localizedUnit,totalSize:totalSizeData.size,totalSizeUnit:totalSizeData.localizedUnit});};LayoutItemView.prototype._updateUI=function(){var fileSizeData;var loadedSizeData;var totalSizeData;var item=this._model;switch(item.state){case item.STATE_PRELOADED:this.container.dataset.enabledAction='none';this._statusEl.dataset.l10nId='preInstalledStatus';fileSizeData=this._getHumanFileSize(item.fileSize);this._statusEl.dataset.l10nArgs=JSON.stringify({size:fileSizeData.size,sizeUnit:fileSizeData.localizedUnit});this._progressEl.classList.add('hide');this._updateList(this.IN_LIST_INSTALLED);break;case item.STATE_INSTALLABLE:this.container.dataset.enabledAction='download';this._statusEl.dataset.l10nId='installableStatus';fileSizeData=this._getHumanFileSize(item.fileSize);this._statusEl.dataset.l10nArgs=JSON.stringify({size:fileSizeData.size,sizeUnit:fileSizeData.localizedUnit});this._progressEl.classList.add('hide');this._updateList(this.IN_LIST_INSTALLABLE);break;case item.STATE_INSTALLING_CANCELLABLE:this.container.dataset.enabledAction='cancel-download';this._statusEl.dataset.l10nId='downloadingStatus';loadedSizeData=this._getHumanFileSize(item.downloadLoadedSize);totalSizeData=this._getHumanFileSize(item.downloadTotalSize);this._statusEl.dataset.l10nArgs=JSON.stringify({loadedSize:loadedSizeData.size,loadedSizeUnit:loadedSizeData.localizedUnit,totalSize:totalSizeData.size,totalSizeUnit:totalSizeData.localizedUnit});this._progressEl.classList.remove('hide');this._progressEl.value=item.downloadLoadedSize;this._progressEl.max=item.downloadTotalSize;this._updateList(this.IN_LIST_INSTALLABLE);break;case item.STATE_INSTALLING:this.container.dataset.enabledAction='none';this._statusEl.dataset.l10nId='downloadingStatus';fileSizeData=this._getHumanFileSize(item.fileSize);this._statusEl.dataset.l10nArgs=JSON.stringify({loadedSize:fileSizeData.size,loadedSizeUnit:fileSizeData.localizedUnit,totalSize:fileSizeData.size,totalSizeUnit:fileSizeData.localizedUnit});this._progressEl.classList.remove('hide');this._progressEl.value=this._progressEl.max=item.fileSize;this._updateList(this.IN_LIST_INSTALLABLE);break;case item.STATE_INSTALLED:this.container.dataset.enabledAction='remove';this._statusEl.dataset.l10nId='installedStatus';fileSizeData=this._getHumanFileSize(item.fileSize);this._statusEl.dataset.l10nArgs=JSON.stringify({size:fileSizeData.size,sizeUnit:fileSizeData.localizedUnit});this._progressEl.classList.add('hide');this._updateList(this.IN_LIST_INSTALLED);break;case item.STATE_REMOVING:this.container.dataset.enabledAction='none';this._statusEl.dataset.l10nId='removingStatus';delete this._statusEl.dataset.l10nArgs;this._progressEl.classList.add('hide');this._updateList(this.IN_LIST_INSTALLED);break;}};LayoutItemView.prototype._showEnableDialog=function(){this.list.confirmEnable(this._model.name).then(function(confirmed){if(confirmed){return this.list.enableLayout(this._model.id);}}.bind(this)).catch(function(e){e&&console.error(e);});};LayoutItemView.prototype._showError=function(errorInfo){switch(errorInfo.error){case errorInfo.ERROR_DOWNLOADERROR:this.list.showDownloadErrorToast();break;case errorInfo.ERROR_INSTALLERROR:console.error('LayoutItemView: layout installation error.');break;case errorInfo.ERROR_REMOVEERROR:console.error('LayoutItemView: layout removal error.');break;default:console.error('LayoutItemView: undefined onerror call,',errorInfo);break;}};LayoutItemView.prototype.handleEvent=function(evt){var el=evt.target;var p;switch(el.dataset.action){case'download':p=this.list.confirmDownload().then(function(confirmed){if(confirmed){return this._model.install();}}.bind(this));break;case'cancelDownload':p=this._model.cancelInstall();break;case'remove':p=this.list.confirmRemoval(this._model.name).then(function(confirmed){if(!confirmed){return;} +return this.list.disableLayout(this._model.id).then(function(){return this._model.remove();}.bind(this));}.bind(this));break;} +if(p){p.catch(function(e){e&&console.error(e);});}};LayoutItemView.prototype.stop=function(){this._model.onprogress=null;this._model.onstatechange=null;this.container.removeEventListener('click',this);this.container=null;};LayoutItemView.prototype._updateList=function(newListState){if(newListState===this.inList){return;} +if(typeof this.inList==='undefined'){this.inList=newListState;return;} +this.inList=newListState;if(typeof this.oninlistchange==='function'){this.oninlistchange();}};LayoutItemView.prototype._getHumanFileSize=function(sizeInNumber){var _=navigator.mozL10n.get;var sizeString='';var localizedUnit='';if(sizeInNumber>(1<<30)){sizeString=(sizeInNumber/(1<<30)).toFixed(2);localizedUnit=_('byteUnit-GB');}else if(sizeInNumber>(1<<20)){sizeString=(sizeInNumber/(1<<20)).toFixed(2);localizedUnit=_('byteUnit-MB');}else if(sizeInNumber>(1<<10)){sizeString=(sizeInNumber/(1<<10)).toFixed(2);localizedUnit=_('byteUnit-KB');}else{sizeString=sizeInNumber.toFixed(2);localizedUnit=_('byteUnit-B');} +sizeString=sizeString.replace(/\.?0*$/,'');return{size:sizeString,localizedUnit:localizedUnit};};exports.LayoutItemView=LayoutItemView;}(window));;'use strict';(function(exports){var LayoutItemDownloadErrorToastView=function(){BaseView.apply(this);this._isVisible=false;this._showDelayed=false;};LayoutItemDownloadErrorToastView.prototype=Object.create(BaseView.prototype);LayoutItemDownloadErrorToastView.prototype.CONTAINER_ID='installable-keyboards-download-error-toast';LayoutItemDownloadErrorToastView.prototype.start=function(){BaseView.prototype.start.call(this);this._isVisible=false;this._showDelayed=false;};LayoutItemDownloadErrorToastView.prototype.stop=function(){BaseView.prototype.stop.call(this);this._isVisible=false;this._showDelayed=false;};LayoutItemDownloadErrorToastView.prototype.show=function(){BaseView.prototype.show.call(this);this._isVisible=true;if(this._showDelayed){this.container.show();this._showDelayed=false;}};LayoutItemDownloadErrorToastView.prototype.beforeHide=function(){BaseView.prototype.beforeHide.call(this);this._isVisible=false;this.container.hide();};LayoutItemDownloadErrorToastView.prototype.showToast=function(){if(!this._isVisible){this._showDelayed=true;return;} +this.container.show();};var Deferred=function(){this.promise=new Promise(function(resolve,reject){this.resolve=resolve;this.reject=reject;}.bind(this));};var DialogInfo=function(args){this.args=args;this.deferred=new Deferred();};var ConfirmationDialogBaseView=function(){BaseView.apply(this);this._isVisible=false;this._isShown=undefined;this._currentDialogInfo=null;this._delayedDialogInfo=null;};ConfirmationDialogBaseView.prototype=Object.create(BaseView.prototype);ConfirmationDialogBaseView.prototype.CONTAINER_ID=undefined;ConfirmationDialogBaseView.prototype.start=function(){if(!this.CONTAINER_ID){throw new Error('ConfirmationDialogBaseView: CONTAINER_ID unset.');} +BaseView.prototype.start.call(this);this._delayedDialogInfo=[];this.container.hidden=true;this._isShown=false;this.container.addEventListener('confirm',this);this.container.addEventListener('cancel',this);};ConfirmationDialogBaseView.prototype.stop=function(){this.container.removeEventListener('confirm',this);this.container.removeEventListener('cancel',this);this._delayedDialogInfo.forEach(function(info){info.deferred.reject('stopped');});this._delayedDialogInfo=null;BaseView.prototype.stop.call(this);};ConfirmationDialogBaseView.prototype.show=function(){BaseView.prototype.show.call(this);this._isVisible=true;this._maybeShowNextDelayedDialog();};ConfirmationDialogBaseView.prototype.handleEvent=function(evt){switch(evt.type){case'confirm':this.hideDialog(true);break;case'cancel':this.hideDialog(false);break;default:throw new Error('ConfirmationDialogBaseView: Unknown event.');}};ConfirmationDialogBaseView.prototype.showDialog=function(){var info=new DialogInfo(Array.prototype.slice.call(arguments));this._delayedDialogInfo.push(info);this._maybeShowNextDelayedDialog();return info.deferred.promise;};ConfirmationDialogBaseView.prototype.beforeShowDialog=function(){};ConfirmationDialogBaseView.prototype._maybeShowNextDelayedDialog=function(){if(!this._delayedDialogInfo.length||!this._isVisible||this._isShown){return;} +var info=this._delayedDialogInfo.shift();this.beforeShowDialog.apply(this,info.args);this.container.hidden=false;this._isShown=true;this._currentDialogInfo=info;};ConfirmationDialogBaseView.prototype.beforeHide=function(){this._isVisible=false;if(this._isShown){this.hideDialog(false);} +BaseView.prototype.beforeHide.call(this);};ConfirmationDialogBaseView.prototype.hideDialog=function(confirmed){if(!this._isShown){throw new Error('ConfirmationDialogBaseView: hideDialog() called when not shown.');} +this._currentDialogInfo.deferred.resolve(confirmed);this.container.hidden=true;this._isShown=false;this._currentDialogInfo=null;this._maybeShowNextDelayedDialog();};var LayoutItemRemovalConfirmationDialogView=function(){ConfirmationDialogBaseView.apply(this);};LayoutItemRemovalConfirmationDialogView.prototype=Object.create(ConfirmationDialogBaseView.prototype);LayoutItemRemovalConfirmationDialogView.prototype.CONTAINER_ID='installable-keyboards-removal-dialog';LayoutItemRemovalConfirmationDialogView.prototype.beforeShowDialog=function(label){ConfirmationDialogBaseView.prototype.beforeShowDialog(this);this.container.firstElementChild.dataset.l10nArgs=JSON.stringify({keyboard:label});};var LayoutItemDataConnectionConfirmationDialogView=function(){ConfirmationDialogBaseView.apply(this);this.rememberMyChoiceElement=null;};LayoutItemDataConnectionConfirmationDialogView.prototype=Object.create(ConfirmationDialogBaseView.prototype);LayoutItemDataConnectionConfirmationDialogView.prototype.CONTAINER_ID='installable-keyboards-mobile-download-dialog';LayoutItemDataConnectionConfirmationDialogView.prototype.REMEMBER_MY_CHOICE_ID='installable-keyboards-remember';LayoutItemDataConnectionConfirmationDialogView.prototype.start=function(){ConfirmationDialogBaseView.prototype.start.apply(this);this.rememberMyChoiceElement=document.getElementById(this.REMEMBER_MY_CHOICE_ID);};LayoutItemDataConnectionConfirmationDialogView.prototype.stop=function(){ConfirmationDialogBaseView.prototype.stop.apply(this);this.rememberMyChoiceElement=null;};LayoutItemDataConnectionConfirmationDialogView.prototype.beforeShowDialog=function(){ConfirmationDialogBaseView.prototype.beforeShowDialog(this);this.rememberMyChoiceElement.checked=false;};LayoutItemDataConnectionConfirmationDialogView.prototype.shouldRemember=function(){return this.rememberMyChoiceElement.checked;};var LayoutItemEnableConfirmationDialogView=function(){ConfirmationDialogBaseView.apply(this);};LayoutItemEnableConfirmationDialogView.prototype=Object.create(ConfirmationDialogBaseView.prototype);LayoutItemEnableConfirmationDialogView.prototype.CONTAINER_ID='installable-keyboards-enable-dialog';LayoutItemEnableConfirmationDialogView.prototype.beforeShowDialog=function(label){ConfirmationDialogBaseView.prototype.beforeShowDialog(this);this.container.firstElementChild.dataset.l10nArgs=JSON.stringify({keyboard:label});};var LayoutItemListView=function(app){BaseView.apply(this);this.app=app;this._model=null;this._installableListContainer=null;this._installedListContainer=null;};LayoutItemListView.prototype=Object.create(BaseView.prototype);LayoutItemListView.prototype.CONTAINER_ID='installable-keyboard';LayoutItemListView.prototype.INSTALLED_LIST_ID='installed-keyboards-list';LayoutItemListView.prototype.INSTALLABLE_LIST_ID='installable-keyboards-list';LayoutItemListView.prototype.start=function(){BaseView.prototype.start.call(this);this._model=new LayoutItemList(this.app);this._model.onready=this._handleModelReady.bind(this);this._model.start();this.childViews.removeDialog=new LayoutItemRemovalConfirmationDialogView();this.childViews.removeDialog.start();this.childViews.downloadErrorToast=new LayoutItemDownloadErrorToastView();this.childViews.downloadErrorToast.start();this.childViews.dataConnectionDialog=new LayoutItemDataConnectionConfirmationDialogView();this.childViews.dataConnectionDialog.start();this.childViews.enableDialog=new LayoutItemEnableConfirmationDialogView();this.childViews.enableDialog.start();this._installedListContainer=document.getElementById(this.INSTALLED_LIST_ID);this._installableListContainer=document.getElementById(this.INSTALLABLE_LIST_ID);};LayoutItemListView.prototype.confirmDownload=function(){var downloadPreference=this._model.downloadPreference;return downloadPreference.getCurrentState().then(function(state){switch(state){case downloadPreference.STATE_PROMPT:return this._showConfirmDownloadDialog();case downloadPreference.STATE_ALLOW:return true;case downloadPreference.STATE_DENY:return false;}}.bind(this));};LayoutItemListView.prototype._showConfirmDownloadDialog=function(){var downloadPreference=this._model.downloadPreference;var dataConnectionDialog=this.childViews.dataConnectionDialog;return dataConnectionDialog.showDialog().then(function(confirmed){if(confirmed&&dataConnectionDialog.shouldRemember()){downloadPreference.setDataConnectionDownloadState(downloadPreference.STATE_ALLOW).catch(function(e){e&&console.error(e);});} +return confirmed;}.bind(this));};LayoutItemListView.prototype.confirmRemoval=function(layoutName){return this.childViews.removeDialog.showDialog(layoutName);};LayoutItemListView.prototype.confirmEnable=function(layoutName){return this.childViews.enableDialog.showDialog(layoutName);};LayoutItemListView.prototype.disableLayout=function(id){return this._model.layoutEnabler.disableLayout(id);};LayoutItemListView.prototype.enableLayout=function(id){return this._model.layoutEnabler.enableLayout(id);};LayoutItemListView.prototype.showDownloadErrorToast=function(){this.childViews.downloadErrorToast.showToast();};LayoutItemListView.prototype._handleModelReady=function(){this._model.onready=null;var layoutItems=this._model.layoutItems;if(layoutItems.size===0){return;} +this.container.hidden=false;this._model.layoutItems.forEach(function(layoutItem,layoutId){var layoutItemView=this.childViews[layoutId]=new LayoutItemView(this,layoutItem);layoutItemView.oninlistchange=function(){Array.from(this._model.layoutItems).map(function(keyValue){return keyValue[0];}).forEach(function(id){this._putItemViewInList(this.childViews[id]);}.bind(this));}.bind(this);layoutItemView.start();this._putItemViewInList(layoutItemView);},this);};LayoutItemListView.prototype._putItemViewInList=function(layoutItemView){var container;switch(layoutItemView.inList){case layoutItemView.IN_LIST_INSTALLED:container=this._installedListContainer;break;case layoutItemView.IN_LIST_INSTALLABLE:container=this._installableListContainer;break;default:throw new Error('LayoutItemListView: '+'Unknown inList state of child view.');} +container.appendChild(layoutItemView.container);};LayoutItemListView.prototype.stop=function(){BaseView.prototype.stop.call(this);this._model.onready=null;this._model.stop();this._model=null;this._installedListContainer=null;for(var id in this.childViews){this.childViews[id].stop();this.childViews[id]=null;}};exports.LayoutItemDownloadErrorToastView=LayoutItemDownloadErrorToastView;exports.LayoutItemRemovalConfirmationDialogView=LayoutItemRemovalConfirmationDialogView;exports.LayoutItemListView=LayoutItemListView;}(window));;;'use strict';(function(exports){var KeyboardSettingsApp=function KeyboardSettingsApp(){this.closeLockManager=null;this.settingsPromiseManager=null;this.preferencesStore=null;this.panelController=null;this.dialogController=null;this._closeLock=null;};KeyboardSettingsApp.prototype.PREFERENCES_STORE_NAME='preferences';KeyboardSettingsApp.prototype.start=function(){this.closeLockManager=new CloseLockManager();this.closeLockManager.onclose=this.close.bind(this);this.closeLockManager.start();this.settingsPromiseManager=new SettingsPromiseManager();this.preferencesStore=new PromiseStorage(this.PREFERENCES_STORE_NAME);this.preferencesStore.start();this.panelController=new PanelController(this);this.panelController.start();this.dialogController=new DialogController();this.dialogController.start();document.addEventListener('visibilitychange',this);};KeyboardSettingsApp.prototype.stop=function(){this.closeLockManager.stop();this.closeLockManager=null;this.settingsPromiseManager=null;this.preferencesStore.stop();this.preferencesStore=null;this.panelController.stop();this.panelController=null;this.dialogController.stop();this.dialogController=null;document.removeEventListener('visibilitychange',this);};KeyboardSettingsApp.prototype.close=function(){this.stop();window.close();};KeyboardSettingsApp.prototype.requestClose=function(){Promise.resolve(new MozActivity({name:'moz_configure_window',data:{target:'device'}})).catch(function(e){console.error(e);});};KeyboardSettingsApp.prototype.handleEvent=function(evt){if(document.hidden){this._closeLock=this.closeLockManager.requestLock('requestClose');}else if(this._closeLock){this._closeLock.unlock();this._closeLock=null;}};exports.KeyboardSettingsApp=KeyboardSettingsApp;})(window);;'use strict';(function(exports){var app=new KeyboardSettingsApp();app.start();exports.app=app;})(window); \ No newline at end of file diff --git a/overrides/settings.gaiamobile.org/js/main.js b/overrides/settings.gaiamobile.org/js/main.js index a45439d4..f680450b 100644 --- a/overrides/settings.gaiamobile.org/js/main.js +++ b/overrides/settings.gaiamobile.org/js/main.js @@ -223,10 +223,124 @@ break;} var cset={};cset[key]=value;_settings.createLock().set(cset);}};});define('modules/settings_panel',['require','modules/panel','modules/settings_cache','modules/panel_utils'],function(require){'use strict';var Panel=require('modules/panel');var SettingsCache=require('modules/settings_cache');var PanelUtils=require('modules/panel_utils');var _emptyFunc=function panel_emptyFunc(){};var SettingsPanel=function ctor_SettingsPanel(options){var _panel=null;var _settingsChangeHandler=function(event){PanelUtils.onSettingsChange(_panel,event);};var _addListeners=function panel_addListeners(panel){if(!panel){return;} SettingsCache.addEventListener('settingsChange',_settingsChangeHandler);panel.addEventListener('change',PanelUtils.onInputChange);panel.addEventListener('click',PanelUtils.onLinkClick);};var _removeListeners=function panel_removeListeners(panel){if(!panel){return;} SettingsCache.removeEventListener('settingsChange',_settingsChangeHandler);panel.removeEventListener('change',PanelUtils.onInputChange);panel.removeEventListener('click',PanelUtils.onLinkClick);};options=options||{};options.onInit=options.onInit||_emptyFunc;options.onUninit=options.onUninit||_emptyFunc;options.onShow=options.onShow||_emptyFunc;options.onHide=options.onHide||_emptyFunc;options.onBeforeShow=options.onBeforeShow||_emptyFunc;options.onBeforeHide=options.onBeforeHide||_emptyFunc;return Panel({onInit:function(panel,initOptions){if(!panel){return;} -_panel=panel;PanelUtils.activate(panel);return options.onInit(panel,initOptions);},onUninit:function(){_removeListeners(_panel);_panel=null;options.onUninit();},onShow:function(panel,showOptions){return options.onShow(panel,showOptions);},onHide:function(){_removeListeners(_panel);return options.onHide();},onBeforeShow:function(panel,beforeShowOptions){PanelUtils.preset(panel);_addListeners(panel);return options.onBeforeShow(panel,beforeShowOptions);},onBeforeHide:function(){return options.onBeforeHide();}});};return SettingsPanel;});define('modules/panel_cache',['require','modules/settings_panel','shared/lazy_loader'],function(require){'use strict';var SettingsPanel=require('modules/settings_panel');var LazyLoader=require('shared/lazy_loader');var _panelCache={};var _panelStylesheetsLoaded=false;var _loadPanelStylesheetsIfNeeded=function loadPanelCSS(){if(_panelStylesheetsLoaded){return;} -LazyLoader.load(['shared/style/action_menu.css','shared/style/confirm.css','shared/style/progress_activity.css','shared/elements/gaia-icons/bidi-helper.css','shared/elements/gaia_buttons/script.js','shared/elements/gaia_confirm/script.js','style/homescreens.css','style/apps.css','style/screen_lock.css','style/simcard.css','style/updates.css','style/downloads.css','style/developer_service_workers.css'],function callback(){_panelStylesheetsLoaded=true;});};navigator.addIdleObserver({time:3,onidle:_loadPanelStylesheetsIfNeeded});return{reset:function spc_reset(){_panelCache={};_panelStylesheetsLoaded=false;},get:function spc_get(panelId,callback){if(!panelId&&!callback){return;} -if(panelId!=='root'){_loadPanelStylesheetsIfNeeded();} -var cachedPanel=_panelCache[panelId];if(cachedPanel){if(callback){callback(cachedPanel);}}else{var panelElement=document.getElementById(panelId);if(panelElement){var pathElement=panelElement.querySelector('panel');var path=pathElement?pathElement.dataset.path:null;var panelFuncLoaded=function(panelFunc){var panel=panelFunc();_panelCache[panelId]=panel;if(callback){callback(panel);}};if(path){require([path],function(panelFunc){panelFuncLoaded(panelFunc?panelFunc:SettingsPanel);});}else{panelFuncLoaded(SettingsPanel);}}else{if(callback){callback(null);}}}}};});var ScreenLayout={defaultQueries:{tiny:'(max-width: 767px)',small:'(min-width: 768px) and (max-width: 991px)',medium:'(min-width: 992px) and (max-width: 1200px)',large:'(min-width: 1201px)',hardwareHomeButton:window.VulpesCompat?'(min-width: 0px)':'(-moz-physical-home-button)'},init:function sl_init(){this.queries=(function(qs){var result={};for(var key in qs){result[key]=window.matchMedia(qs[key]);} +_panel=panel;PanelUtils.activate(panel);return options.onInit(panel,initOptions);},onUninit:function(){_removeListeners(_panel);_panel=null;options.onUninit();},onShow:function(panel,showOptions){return options.onShow(panel,showOptions);},onHide:function(){_removeListeners(_panel);return options.onHide();},onBeforeShow:function(panel,beforeShowOptions){PanelUtils.preset(panel);_addListeners(panel);return options.onBeforeShow(panel,beforeShowOptions);},onBeforeHide:function(){return options.onBeforeHide();}});};return SettingsPanel;});define('modules/panel_cache',["require", "modules/settings_panel", "shared/lazy_loader"],function(require) { + 'use strict'; + + var SettingsPanel = require('modules/settings_panel'); + var LazyLoader = require('shared/lazy_loader'); + + var _panelCache = {}; + var _panelStylesheetsLoaded = false; + + // experiment result shows + // load all related styles once is time saver + var _loadPanelStylesheetsIfNeeded = function loadPanelCSS() { + if (_panelStylesheetsLoaded) { + return; + } + + LazyLoader.load(['shared/style/action_menu.css', + 'shared/style/confirm.css', + 'shared/style/progress_activity.css', + 'shared/elements/gaia-icons/bidi-helper.css', + 'shared/elements/gaia_buttons/script.js', + 'shared/elements/gaia_confirm/script.js', + 'style/homescreens.css', + // Bug 1210284 - disabling achievements until they are on + // the roadmap again or completely removed. + // 'style/achievements.css', + 'style/apps.css', + 'style/screen_lock.css', + 'style/simcard.css', + 'style/updates.css', + 'style/downloads.css', + 'style/developer_service_workers.css'], + function callback() { + _panelStylesheetsLoaded = true; + }); + }; + + // load styles in idle time after document loaded + navigator.addIdleObserver({ + time: 3, + onidle: _loadPanelStylesheetsIfNeeded + }); + + return { + // this is for unit test + reset: function spc_reset() { + _panelCache = {}; + _panelStylesheetsLoaded = false; + }, + + /** + * Get the panel module of a specified id. If there is no corresponding + * panel module of the id, it returns SettingsPanel. + * + * @alias module:PanelCache#get + * @param {String} panelId + * The id of the to be loaded panel. + * @param {Function} callback + * The function to be called when the panel is loaded. + */ + get: function spc_get(panelId, callback) { + if (!panelId && !callback) { + return; + } + + if (panelId !== 'root') { + _loadPanelStylesheetsIfNeeded(); + } + + var cachedPanel = _panelCache[panelId]; + if (cachedPanel) { + if (callback) { + callback(cachedPanel); + } + } else { + // Get the path of the panel creation function + var panelElement = document.getElementById(panelId); + if (panelElement) { + var pathElement = panelElement.querySelector('panel'); + var path = pathElement ? pathElement.dataset.path : null; + + var panelFuncLoaded = function(panelFunc) { + var panel; + try { + panel = panelFunc(); + if (!panel || typeof panel.beforeShow !== 'function') { + throw new TypeError('Invalid settings panel: ' + panelId); + } + } catch (error) { + if (callback) callback(null, error); + return; + } + _panelCache[panelId] = panel; + if (callback) { + callback(panel); + } + }; + + if (path) { + require([path], function(panelFunc) { + // Create a new panel object for static panels. + panelFuncLoaded(panelFunc ? panelFunc : SettingsPanel); + }, function(error) { + if (callback) callback(null, error); + }); + } else { + panelFuncLoaded(SettingsPanel); + } + } else { + if (callback) { + callback(null); + } + } + } + } + }; +}); +var ScreenLayout={defaultQueries:{tiny:'(max-width: 767px)',small:'(min-width: 768px) and (max-width: 991px)',medium:'(min-width: 992px) and (max-width: 1200px)',large:'(min-width: 1201px)',hardwareHomeButton:window.VulpesCompat?'(min-width: 0px)':'(-moz-physical-home-button)'},init:function sl_init(){this.queries=(function(qs){var result={};for(var key in qs){result[key]=window.matchMedia(qs[key]);} return result;})(this.defaultQueries);},_isOnRealDevice:undefined,isOnRealDevice:function sl_isOnRealDevice(){if(typeof(this._isOnRealDevice)!=='undefined'){return this._isOnRealDevice;} if(window.innerWidth===screen.availWidth){this._isOnRealDevice=true;}else{this._isOnRealDevice=false;} return this._isOnRealDevice;},getCurrentLayout:function sl_getCurrentLayout(type){if(type===undefined){for(var name in this.defaultQueries){if(this.queries[name]&&this.queries[name].matches){return name;}}} @@ -443,7 +557,12 @@ define('modules/settings_service',["require", "modules/page_transitions", "modul } _loadPanel(panelId, function() { - PanelCache.get(panelId, function(panel) { + PanelCache.get(panelId, function(panel, loadError) { + if (loadError || !panel) { + console.error('Settings panel ' + panelId + ':', loadError); + callback(loadError || new Error('Missing settings panel: ' + panelId)); + return; + } var newPanelElement = document.getElementById(panelId); var currentPanelId = _currentNavigation && _currentNavigation.panelId; diff --git a/release.json b/release.json index 7eb7c4a2..d7b51032 100644 --- a/release.json +++ b/release.json @@ -1,15 +1,15 @@ { "gaia": "2.7.0", "channel": "preview", - "revision": 20, - "androidVersionCode": 20, + "revision": 21, + "androidVersionCode": 21, "geckoview": "156.0.20260921121718", "engineAlignment": { "family": "156", "patchParity": false, "reason": "Firefox desktop 156.0.1; Mozilla's GeckoView release repository currently offers 156.0 only. Keep the desktop patch and report actual engine versions." }, - "version": "2.7-preview.20", + "version": "2.7-preview.21", "adapters": { "desktop": "0.1.0-dev", "android": "0.1.0-dev" diff --git a/tests/android-input-bridge.test.mjs b/tests/android-input-bridge.test.mjs index ae1e5893..804569f0 100644 --- a/tests/android-input-bridge.test.mjs +++ b/tests/android-input-bridge.test.mjs @@ -3,10 +3,10 @@ import assert from 'node:assert/strict'; import vm from 'node:vm'; import {readFileSync} from 'node:fs'; const source=readFileSync(new URL('../android/extension/content.js',import.meta.url),'utf8'); -async function setup(enabled=true) { - const sent=[],events=new Map();let receive; +async function setup(enabled=true,{defer=false,activeElement}={}) { + const sent=[],events=new Map(),timers=[];let receive,disconnect; const page={navigator:{},dispatchEvent(){}}; - const port={postMessage:m=>sent.push(m),onMessage:{addListener:fn=>receive=fn},onDisconnect:{addListener(){}}}; + const port={postMessage:m=>sent.push(m),onMessage:{addListener:fn=>receive=fn},onDisconnect:{addListener:fn=>disconnect=fn}}; class Editor { constructor(){this.editing=false;} focus(e){this.inputElement=e;} @@ -14,14 +14,14 @@ async function setup(enabled=true) { edit(){return this.state();} } const context={InputEditor:Editor,browser:{runtime:{connect:()=>port}},console, - location:{port:'18765',hostname:'sms.localhost'},document:{hidden:false}, + location:{port:'18765',hostname:'sms.localhost'},document:{hidden:false,activeElement,hasFocus:()=>true}, window:{wrappedJSObject:page,Promise,Error,Event,CustomEvent:class extends Event{constructor(name,p){super(name);this.detail=p.detail;}}}, cloneInto:x=>x,exportFunction:(fn,obj,p)=>{obj[p.defineAs]=fn;}, - addEventListener:(name,fn)=>events.set(name,fn),setTimeout,clearTimeout}; + addEventListener:(name,fn)=>events.set(name,fn),setTimeout:fn=>timers.push(fn),clearTimeout(){}}; vm.runInNewContext(source,context); - receive({id:sent[0].id,reply:{result:enabled}}); + if(!defer) receive({id:sent[0].id,reply:{result:enabled}}); await new Promise(resolve=>setImmediate(resolve)); - return {sent,events,page,receive,context}; + return {sent,events,page,receive,context,timers,disconnect}; } function field(mode='numeric') { const attrs=new Map([['inputmode',mode]]); @@ -51,3 +51,54 @@ test('Android keyboard remains the default and hidden Gaia restores the editor m assert.equal(e.getAttribute('inputmode'),'numeric'); assert.equal(s.sent.at(-1).inputFocus,null); }); + +test('a field focused before the preference arrives activates Gaia without another tap',async()=>{ + const e=field(),s=await setup(true,{defer:true,activeElement:e}); + s.events.get('focusin')({target:e}); + assert.equal(s.sent.length,1); + s.receive({id:s.sent[0].id,reply:{result:true}}); + await new Promise(resolve=>setImmediate(resolve)); + assert.equal(e.getAttribute('inputmode'),'none'); + assert.equal(s.sent.at(-1).inputFocus.inputMode,'numeric'); +}); + +test('an old preference reply cannot undo a newer keyboard toggle',async()=>{ + const e=field(),s=await setup(false,{defer:true,activeElement:e}); + s.receive({event:{type:'input-mode',data:{enabled:true}}}); + s.receive({id:s.sent[0].id,reply:{result:false}}); + await new Promise(resolve=>setImmediate(resolve)); + assert.equal(s.page.VulpesNativeInput,true); + assert.equal(e.getAttribute('inputmode'),'none'); +}); + +test('returning to an app restores its focused Gaia editor',async()=>{ + const e=field(),s=await setup(true,{activeElement:e}); + s.context.document.hidden=true;s.events.get('visibilitychange')(); + assert.equal(s.sent.at(-1).inputFocus,null); + s.context.document.hidden=false;s.events.get('visibilitychange')(); + assert.equal(s.sent.at(-1).inputFocus.inputMode,'numeric'); + assert.equal(e.getAttribute('inputmode'),'none'); +}); + +test('bridge reconnection rereads the preference and restores the active editor',async()=>{ + const e=field(),s=await setup(true,{activeElement:e}); + s.disconnect(); + assert.equal(e.getAttribute('inputmode'),'numeric'); + assert.equal(s.page.VulpesNativeInput,false); + s.timers.at(-1)(); + const request=s.sent.at(-1); + assert.equal(request.request.method,'input.config'); + s.receive({id:request.id,reply:{result:true}}); + await new Promise(resolve=>setImmediate(resolve)); + assert.equal(e.getAttribute('inputmode'),'none'); + assert.equal(s.sent.at(-1).inputFocus.inputMode,'numeric'); +}); + +test('a background app cannot reclaim the keyboard when Android resumes',async()=>{ + const e=field(),s=await setup(true,{activeElement:e}); + s.context.document.hidden=true;s.events.get('visibilitychange')(); + s.context.document.hidden=false;s.context.document.hasFocus=()=>false; + s.events.get('visibilitychange')(); + assert.equal(s.sent.at(-1).inputFocus,null); + assert.equal(e.getAttribute('inputmode'),'numeric'); +}); diff --git a/tests/android-personal-data.test.mjs b/tests/android-personal-data.test.mjs new file mode 100644 index 00000000..66f34be5 --- /dev/null +++ b/tests/android-personal-data.test.mjs @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {Contacts} from '../host/Contacts.sys.mjs'; +import {Messages} from '../host/Messages.sys.mjs'; +const source=readFileSync('android/extension/personal-data.mjs','utf8') + .replace("'./Contacts.mjs'",JSON.stringify(new URL('../host/Contacts.sys.mjs',import.meta.url).href)) + .replace("'./Messages.mjs'",JSON.stringify(new URL('../host/Messages.sys.mjs',import.meta.url).href)); +const {AndroidPersonalData,authorizePersonal,smsFromAndroid}=await import('data:text/javascript;base64,'+Buffer.from(source).toString('base64')); +const app={id:'communications',manifest:{permissions:{contacts:{access:'readwrite'}}}}; +const smsApp={id:'sms',manifest:{permissions:{sms:{},contacts:{access:'readonly'}}}}; +async function setup(){ + const data=new Map([['vulpes-private-contacts',{revision:1,contacts:[{id:'local-1',name:['Local']}]}],['vulpes-private-messages',[{id:100,threadId:200,timestamp:1,body:'Local',delivery:'sent',receiver:'5550100',type:'sms',read:true}]]]); + const storage={run:async(name,op,args)=>{if(op==='get')return {value:structuredClone(data.get(name))};data.set(name,structuredClone(args[0]));}}; + const contacts=new Contacts(storage,()=>{});await contacts.init(); + const calls=[];let allowed=true; + const service=new AndroidPersonalData(async p=>{calls.push(p);if(p.operation==='status')return {contacts:allowed,sms:allowed};return {rows:p.kind==='contacts'?[{_id:1,display_name:'Android',tel:[{value:'5550199'}]}]:[{_id:1,thread_id:2,address:'5550199',body:'Android SMS',date:2,type:1,read:0,status:-1}],next:null};},contacts,new Messages(storage),storage); + return {service,data,calls,revoke(){allowed=false;}}; +} +test('native contact reads preserve the local address book and search phone numbers',async()=>{ + const s=await setup();const rows=await s.service.contactRequest(app,{operation:'all'}); + assert.deepEqual(rows.map(r=>r.id),['local-1','android:1']); + assert.equal((await s.service.contactRequest(app,{operation:'find',options:{filterBy:['tel'],filterOp:'match',filterValue:'0199'}}))[0].id,'android:1'); + assert.equal(s.data.get('vulpes-private-contacts').contacts.length,1); + s.revoke();assert.equal((await s.service.contactRequest(app,{operation:'all'})).length,1); +}); +test('native messages and threads remain distinct from local IDs and are not copied to storage',async()=>{ + const s=await setup();const rows=await s.service.messageRequest(smsApp,{operation:'getThreads'}); + assert.deepEqual(rows.map(r=>r.id),[-2,200]); + const [sms]=await s.service.messageRequest(smsApp,{operation:'getMessages',args:[{threadId:-2}]});assert.equal(sms.id,-1);assert.equal(sms.body,'Android SMS'); + assert.equal(s.data.get('vulpes-private-messages').length,1); + s.revoke();assert.equal((await s.service.messageRequest(smsApp,{operation:'getThreads'})).length,1); +}); +test('Android writes never fall through to the local store or report false success',async()=>{ + const s=await setup(); + for(const params of [{operation:'save',contact:{id:'android:1'}},{operation:'remove',id:'android:1'},{operation:'clear'}]) await assert.rejects(s.service.contactRequest(app,params),/READ_ONLY/); + for(const params of [{operation:'delete',args:[[100,-1]]},{operation:'markMessageRead',args:[-1,true]}])await assert.rejects(s.service.messageRequest(smsApp,params),/READ_ONLY/); + assert.equal(s.data.get('vulpes-private-messages').length,1); +}); +test('untrusted installed apps cannot reuse the Android permission granted to Gaia',async()=>{ + const s=await setup(); + assert.equal((await s.service.contactRequest({...app,installed:true},{operation:'all'})).length,1); + assert.equal((await s.service.messageRequest({...smsApp,installed:true},{operation:'getMessages'})).length,1); + assert.equal(s.calls.length,0); + assert.throws(()=>authorizePersonal({...app,installed:true},'contacts','page'),/PERMISSION/); + assert.throws(()=>authorizePersonal(smsApp,'calls','page'),/PERMISSION/); + assert.throws(()=>authorizePersonal(app,'contacts','permission'),/PERMISSION/); + assert.doesNotThrow(()=>authorizePersonal({id:'settings'},'sms','permission')); +}); +test('provider errors and stalled page cursors are surfaced, not presented as an empty book',async()=>{ + const s=await setup();let n=0; + s.service.native=async p=>p.operation==='status'?{contacts:true}:{rows:[],next:++n===1?1:1}; + await assert.rejects(s.service.rows('contacts'),/INVALID_CURSOR/); + s.service.native=async()=>{throw Error('denied');};await assert.rejects(s.service.rows('contacts'),/denied/); +}); +test('SMS mapping does not turn pending or failed sends into delivered messages',()=>{ + for(const [type,delivery] of [[1,'received'],[2,'sent'],[4,'sending'],[5,'error'],[6,'sending']])assert.equal(smsFromAndroid({_id:1,thread_id:1,type}).delivery,delivery); + assert.throws(()=>smsFromAndroid({_id:Infinity,thread_id:1}),/INVALID_ANDROID_ID/); +}); diff --git a/tests/android-personal-ui.test.mjs b/tests/android-personal-ui.test.mjs new file mode 100644 index 00000000..b39fca8d --- /dev/null +++ b/tests/android-personal-ui.test.mjs @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import {readFileSync} from 'node:fs'; +function setup(app,path,call){ + const events=new Map(),notices=[]; + const context={console,URL,Map,Set,Number,JSON,Date,Promise,queueMicrotask,setTimeout,clearTimeout,setInterval,clearInterval, + location:{hostname:app+'.localhost',pathname:path},navigator:{language:'en'},localStorage:{removeItem(){}}, + document:{hidden:false,documentElement:{lang:'en'},readyState:'complete',addEventListener:(type,fn)=>events.set('document-'+type,fn),querySelectorAll(){return[];}}, + MutationObserver:class{observe(){}},addEventListener:(type,fn)=>events.set(type,fn), + VulpesCompat:{call},VulpesGaiaUI:{notice:value=>notices.push(value)}}; + context.window=context;vm.runInNewContext(readFileSync('android/ui/android-ui.js','utf8'),context); + return {context,events,notices}; +} +test('native call rows use Gaia grouping and expose details without changing the local DB',async()=>{ + const s=setup('communications','/dialer/index.html',async(_method,p)=>p.operation==='status'?{calls:true}:[ + {_id:1,date:1000,number:'5550199',duration:42,type:2}, + {_id:2,date:2000,number:'5550199',duration:0,type:2}]); + let localReads=0; + s.context.CallLogDBManager={getGroupList(){localReads++;},getGroup(){localReads++;},getGroupAtPosition(){localReads++;}, + _getGroupId:r=>[0,r.number,r.type],_getGroupObject:g=>({...g,id:g.id.join('-'),type:g.id[2]})}; + const db=s.context.CallLogDBManager; + const rows=await new Promise(resolve=>db.getGroupList(resolve,'lastEntryDate',true,false)); + assert.equal(rows.length,1);assert.equal(rows[0].retryCount,2);assert.equal(rows[0].calls[1].duration,42000); + assert.equal((await db.getGroup('5550199',1000,'dialing')).calls.length,2);assert.equal(localReads,0); + const streamed=[]; + await new Promise(resolve=>db.getGroupList(c=>{if(!c.value)return resolve();streamed.push(c.value);c.continue();},'lastEntryDate',true,true)); + assert.equal(streamed.length,1); +}); +test('SMS provider change refreshes the open conversation without reloading or touching the draft',async()=>{ + const inserted=[]; + const s=setup('sms','/index.html',async()=>[{id:-1,threadId:-2,body:'Received',timestamp:1000}]); + s.context.Navigation={isCurrentPanel:name=>name==='thread'}; + s.context.ConversationView={activeThread:{id:-2},appendMessage:async message=>inserted.push(message)}; + s.context.Compose={getText:()=> 'Unsent draft'}; + s.context.document.getElementById=()=>null; + s.context.location.reload=()=>{throw Error('draft lost');}; + s.events.get('vulpes-service-event')({detail:{type:'android-data-changed',data:{kind:'sms'}}}); + await new Promise(resolve=>setImmediate(resolve)); + assert.equal(inserted.length,1);assert.equal(inserted[0].body,'Received'); + assert.equal(s.context.Compose.getText(),'Unsent draft'); +}); + +test('hidden call log refreshes contact names when shown after Android access changes',()=>{ + const s=setup('communications','/dialer/index.html',async()=>({})); + let updates=0; + s.context.CallLog={sticky:{},_contactCache:true,updateListWithContactInfo(){updates++;}}; + s.context.document.hidden=true; + s.events.get('vulpes-service-event')({detail:{type:'android-data-changed',data:{kind:'contacts'}}}); + assert.equal(updates,0); + s.context.document.hidden=false; + s.events.get('visibilitychange')(); + assert.equal(updates,1); + assert.equal(s.context.CallLog._contactCache,false); +}); + +test('Android contact HTML caches are disabled before Gaia can apply stale names',async()=>{ + const s=setup('communications','/contacts/index.html',async()=>({})); + try { + let writes=0; + s.context.Cache={get enabled(){return true;},get active(){return true;},set firstChunk(value){writes++;},apply:async()=> 'stale names'}; + assert.equal(s.context.Cache.enabled,false); + assert.equal(s.context.Cache.active,false); + s.context.Cache.firstChunk={cache:'Android names'}; + assert.equal(writes,0); + await assert.rejects(s.context.Cache.apply()); + } finally{s.events.get('pagehide')();} +}); + +test('revoked native contact details close when the hidden app becomes visible',async()=>{ + const s=setup('communications','/contacts/index.html',async()=>[]); + try { + let closed=0; + s.context.MainNavigation={currentView:()=> 'view-contact-details'}; + s.context.Contacts={goBack(){closed++;}}; + s.events.get('document-click')({target:{closest:()=>({dataset:{uuid:'android:1'}})}}); + s.context.document.hidden=true; + s.events.get('vulpes-service-event')({detail:{type:'android-data-changed',data:{kind:'contacts'}}}); + assert.equal(closed,0); + s.context.document.hidden=false; + s.events.get('visibilitychange')(); + await new Promise(resolve=>setImmediate(resolve)); + assert.equal(closed,1); + } finally{s.events.get('pagehide')();} +}); diff --git a/tests/call-log-contacts.test.mjs b/tests/call-log-contacts.test.mjs new file mode 100644 index 00000000..8c2e4b32 --- /dev/null +++ b/tests/call-log-contacts.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import {readFileSync} from 'node:fs'; + +for (const file of ['gaia/apps/communications/dialer/js/call_log.js', + 'assets/webapps/communications.gaiamobile.org/dialer/js/call_log.js']) { + test(`call log resolves contacts in its newly rendered section: ${file}`, () => { + const context = {navigator:{mozContacts:{}},window:{}}; + vm.runInNewContext(readFileSync(file,'utf8'),context); + const log = context.CallLog, items = [], lookedUp = []; + const list = {appendChild:row=>items.push(row),querySelectorAll:()=>items}; + log.createSectionForGroup=()=>({getElementsByTagName:()=>[list]}); + log.createGroup=group=>({dataset:{phoneNumber:group.number}}); + log.callLogContainer={appendChild(){}}; + log._updateContact=(_node,number)=>lookedUp.push(number); + log.renderChunk([{number:'5550199'}]); + assert.deepEqual(lookedUp,['5550199']); + }); +} diff --git a/tests/keyboard-dictionaries.test.mjs b/tests/keyboard-dictionaries.test.mjs new file mode 100644 index 00000000..f4991d54 --- /dev/null +++ b/tests/keyboard-dictionaries.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import {readFileSync} from 'node:fs'; +const root=new URL('../',import.meta.url); +const source=readFileSync(new URL('gaia/apps/keyboard/js/settings/layout_dictionary_downloader.js',root),'utf8'); +test('every offered Latin dictionary is present and has a valid header',()=>{ + const layouts=JSON.parse(readFileSync(new URL('assets/webapps/keyboard.gaiamobile.org/js/settings/layouts.json',root))); + for(const name of new Set(layouts.filter(l=>l.imEngineId==='latin'&&l.dictFilename).map(l=>l.dictFilename))){ + const data=readFileSync(new URL('gaia/apps/keyboard/js/imes/latin/dictionaries/'+name,root)); + assert.equal(data.subarray(0,8).toString(),'FxOSDICT',name); + } +}); +test('adding a language reads and verifies its local dictionary',async()=>{ + let requested,aborted=false; + class XHR { + open(method,url){requested=url;} + send(){const b=readFileSync(new URL('gaia/apps/keyboard'+requested,root));this.response=b.buffer.slice(b.byteOffset,b.byteOffset+b.byteLength);this.onloadend();} + abort(){aborted=true;} + } + const window={};vm.runInNewContext(source,{window,XMLHttpRequest:XHR,ArrayBuffer,Uint8Array,Promise}); + const data=await new window.LayoutDictionaryDownloader('latin','it.dict').load(); + assert.equal(requested,'/js/imes/latin/dictionaries/it.dict');assert.ok(data.byteLength>8);assert.equal(aborted,false); +}); diff --git a/tests/keyboard-startup.test.mjs b/tests/keyboard-startup.test.mjs new file mode 100644 index 00000000..9b7641d8 --- /dev/null +++ b/tests/keyboard-startup.test.mjs @@ -0,0 +1,40 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import {readFileSync} from 'node:fs'; + +const source=readFileSync(new URL('../host/gaia-input.js',import.meta.url),'utf8'); +function keyboard() { + const listeners=new Map();let reply; + const parent={postMessage(){}}; + const context={VulpesNativeInput:true,location:{hostname:'keyboard.localhost'}, + VulpesCompat:{call:()=>new Promise(resolve=>reply=resolve)}, + navigator:{},document:{readyState:'loading'},parent,console,Event,EventTarget, + CustomEvent:class extends Event {constructor(name,options){super(name);this.detail=options.detail;}}, + addEventListener(name,fn){listeners.set(name,fn);},dispatchEvent(){}}; + context.window=context; + vm.runInNewContext(source,context); + return { + ime:context.navigator.mozInputMethod, + state:value=>listeners.get('vulpes-service-event')({detail:{type:'input-focus',data:value}}), + reply:value=>reply(value), + viewport:()=>listeners.get('message')({source:parent,origin:'http://system.localhost:8765', + data:{type:'vulpes-keyboard-viewport',width:360,height:740}}) + }; +} +test('an old empty snapshot cannot clear a newly focused keyboard',async()=>{ + const k=keyboard();k.viewport();k.state({id:2,text:'current'});k.reply(null); + await new Promise(resolve=>setImmediate(resolve)); + assert.equal(k.ime.inputcontext.text,'current'); +}); +test('an old active snapshot cannot reopen a dismissed keyboard',async()=>{ + const k=keyboard();k.viewport();k.state(null);k.reply({id:1,text:'old'}); + await new Promise(resolve=>setImmediate(resolve)); + assert.equal(k.ime.inputcontext,null); +}); +test('focus received before viewport measurement survives a stale initial reply',async()=>{ + const k=keyboard();k.state({id:3,text:'pending'});k.reply(null); + await new Promise(resolve=>setImmediate(resolve)); + k.viewport(); + assert.equal(k.ime.inputcontext.text,'pending'); +}); diff --git a/tests/marketplace-navigation.test.mjs b/tests/marketplace-navigation.test.mjs new file mode 100644 index 00000000..bf8ae8e6 --- /dev/null +++ b/tests/marketplace-navigation.test.mjs @@ -0,0 +1,34 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import {readFileSync} from 'node:fs'; +const source=readFileSync(new URL('../gaia/apps/marketplace/marketplace.js',import.meta.url),'utf8'); +function element(){return Object.assign(new EventTarget(),{style:{},hidden:false,textContent:''});} +async function setup() { + const nodes=Object.fromEntries(['catalogue','message','confirm','open','browse','install','cancel','status','title','question','permissions'].map(id=>[id,element()])); + let poll,download=null,finish; + const context={document:{getElementById:id=>nodes[id]},navigator:{language:'en'},console, + addEventListener(){},setInterval:fn=>poll=fn,VulpesCompat:{call:async method=>{ + if(method==='locale.current')return 'en'; + if(method==='marketplace.poll'){const url=download;download=null;return url;} + if(method==='marketplace.prepare')return new Promise(resolve=>finish=resolve); + }}}; + await vm.runInNewContext(source,context); + return {nodes,poll,setDownload:()=>download='https://vulpes-os.org/marketplace/index.php?download=1',finish:()=>finish({name:'Test',bytes:1024,token:'test'})}; +} +test('closing the Android web view exposes a way back to the catalogue, not a blank frame',async()=>{ + const {nodes:n}=await setup(); + n.catalogue.dispatchEvent(new Event('mozbrowserclose')); + assert.equal(n.message.hidden,false);assert.equal(n.catalogue.style.visibility,'hidden'); + assert.match(n.status.textContent,/reopen/);assert.equal(n.open.hidden,true); + n.browse.onclick(); + assert.equal(n.message.hidden,true);assert.equal(n.catalogue.style.visibility,'visible'); + assert.match(n.catalogue.src,/mode=app&lang=en/); +}); +test('a close notification cannot erase an installation confirmation',async()=>{ + const f=await setup();f.setDownload();const pending=f.poll(); + await new Promise(resolve=>setImmediate(resolve)); + f.nodes.catalogue.dispatchEvent(new Event('mozbrowserclose')); + assert.match(f.nodes.status.textContent,/Checking/); + f.finish();await pending;assert.equal(f.nodes.confirm.hidden,false); +}); diff --git a/tests/settings-panel-cache.test.mjs b/tests/settings-panel-cache.test.mjs new file mode 100644 index 00000000..01cc7e87 --- /dev/null +++ b/tests/settings-panel-cache.test.mjs @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import {readFileSync} from 'node:fs'; +const source=readFileSync(new URL('../gaia/apps/settings/js/modules/panel_cache.js',import.meta.url),'utf8'); +function fixture(load) { + let cache; + function require(id,ok,fail) { + if(Array.isArray(id))return load(ok,fail); + if(id==='shared/lazy_loader')return {load:(_,done)=>done()}; + return ()=>({beforeShow(){}}); + } + vm.runInNewContext(source,{define:factory=>cache=factory(require),navigator:{addIdleObserver(){}}, + document:{getElementById:()=>({querySelector:()=>({dataset:{path:'panels/storage/panel'}})})}}); + return cache; +} +for(const failure of ['dependency','constructor','invalid']) test('Panel cache reports '+failure+' failure without caching a broken panel',()=>{ + let broken=true; + const expected=new Error('storage unavailable'); + const cache=fixture((ok,fail)=>{ + if(!broken)return ok(()=>({beforeShow(){}})); + if(failure==='dependency')return fail(expected); + ok(()=>{if(failure==='constructor')throw expected;return {};}); + }); + let calls=0; + cache.get('mediaStorage',(panel,error)=>{calls++;assert.equal(panel,null);assert.ok(error);}); + assert.equal(calls,1);broken=false; + cache.get('mediaStorage',(panel,error)=>{assert.equal(error,undefined);assert.equal(typeof panel.beforeShow,'function');}); +}); diff --git a/tests/settings-startup.test.mjs b/tests/settings-startup.test.mjs new file mode 100644 index 00000000..3a18412e --- /dev/null +++ b/tests/settings-startup.test.mjs @@ -0,0 +1,19 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import {readFileSync} from 'node:fs'; +const source=readFileSync(new URL('../gaia/apps/settings/js/startup.js',import.meta.url),'utf8'); +const bootstrap=source.slice(source.lastIndexOf('(function() {')); +test('Settings waits for deferred components before loading AMD modules',()=>{ + let starts=0; + const document=Object.assign(new EventTarget(),{readyState:'interactive'}); + vm.runInNewContext(bootstrap,{document,AppStarter:()=>({start:()=>starts++})}); + assert.equal(starts,0); + document.dispatchEvent(new Event('DOMContentLoaded'));assert.equal(starts,1); + document.dispatchEvent(new Event('DOMContentLoaded'));assert.equal(starts,1); +}); +test('Settings starts immediately when loaded after the document',()=>{ + let starts=0; + vm.runInNewContext(bootstrap,{document:{readyState:'complete'},AppStarter:()=>({start:()=>starts++})}); + assert.equal(starts,1); +}); diff --git a/tests/test_changelog.py b/tests/test_changelog.py index 9923e3db..a3ac8a12 100644 --- a/tests/test_changelog.py +++ b/tests/test_changelog.py @@ -74,7 +74,7 @@ class ChangelogTests(unittest.TestCase): def test_date_first_heading_and_platform_badges(self): for language in export.LANGUAGES: - entry = self.languages[language][0] + entry = next(e for e in self.languages[language] if e['id'] == '2.7-preview.20') self.assertEqual(entry['id'], '2.7-preview.20') self.assertEqual(entry['date'], '2026-10-13') common = next(g for g in entry['groups'] if g['common']) @@ -98,7 +98,7 @@ class ChangelogTests(unittest.TestCase): def test_release_summary_follows_the_entry_without_copying_text(self): tag = 'v2.7-preview.20' - self.source['entries'][0]['text']['fr'] += '\nA new correction.\n' + next(e for e in self.source['entries'] if e['id'] == '2.7-preview.20')['text']['fr'] += '\nA new correction.\n' docs = export.release_documents(self.source, tag) self.assertIn('A new correction.', docs['release-body.md']) diff --git a/tools/check-parity.py b/tools/check-parity.py index 51ded613..305d5adb 100644 --- a/tools/check-parity.py +++ b/tools/check-parity.py @@ -2,7 +2,7 @@ """Reject stale Android payloads and inconsistent common release metadata.""" import argparse, contextlib, hashlib, io, json, re, zipfile -from project import ROOT, release_info +from project import ROOT, release_info, SOURCE_OVERRIDES p = argparse.ArgumentParser() group = p.add_mutually_exclusive_group() @@ -72,6 +72,7 @@ with source as archive: assert actual == expected.encode(), "Stale Android Gaia panel: " + path results[app + "/" + path] = hashlib.sha256(actual).hexdigest() for app, path, source in [ + ("keyboard", "gaia_build_defer_settings.js", "overrides"), ("calendar", "gaia_build_defer_index.js", "overrides"), ("calendar", "js/bundle.js", "overrides"), ("gallery", "js/metadata_scripts.js", "overrides"), @@ -94,6 +95,13 @@ with source as archive: actual = gaia.read(app + "/" + path) assert actual == expected, "Stale application workflow: " + app + "/" + path results[app + "/" + path] = hashlib.sha256(actual).hexdigest() + for app in ("keyboard", "settings"): + for name in SOURCE_OVERRIDES[app]: + expected = (ROOT / "gaia/apps" / app / name).read_bytes() + if name.endswith(".js"): + expected = translate(expected.decode()).encode() + assert gaia.read(app + "/" + name) == expected, "Stale shared source: " + app + "/" + name + results[app + "/" + name] = hashlib.sha256(expected).hexdigest() helper = "dialer/js/telephony_helper.js" expected = translate((ROOT / "gaia/apps/communications" / helper).read_text()).encode() assert gaia.read("communications/" + helper) == expected, "Stale call-log dial helper" diff --git a/tools/project.py b/tools/project.py index 5e5acdf5..670f1cce 100644 --- a/tools/project.py +++ b/tools/project.py @@ -11,6 +11,11 @@ SOURCE_OVERRIDES = { "system": ["js/launcher.js", "js/gaia_scheduler.js", "js/keyboard_manager.js", "js/core.js", "js/rocketbar.js"], "communications": ["dialer/js/telephony_helper.js"], + "settings": ["js/modules/storage_helper.js", "js/startup.js"], + "keyboard": ["js/settings/layout_dictionary_downloader.js"] + [ + str(path.relative_to(ROOT / "gaia/apps/keyboard")) + for path in sorted((ROOT / "gaia/apps/keyboard/js/imes/latin/dictionaries").glob("*.dict")) + ], } diff --git a/tools/refresh-keyboard.py b/tools/refresh-keyboard.py new file mode 100644 index 00000000..51b81ec9 --- /dev/null +++ b/tools/refresh-keyboard.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +"""Refresh the dictionary loader in Gaia's packaged keyboard settings bundle.""" +from pathlib import Path +ROOT=Path(__file__).resolve().parents[1] +source=(ROOT/'gaia/apps/keyboard/js/settings/layout_dictionary_downloader.js').read_text() +path=ROOT/'assets/webapps/keyboard.gaiamobile.org/gaia_build_defer_settings.js' +bundle=path.read_text() +start=bundle.index('(function(exports){var LayoutDictionaryDownloader=') +end=bundle.index('exports.LayoutDictionaryDownloader=LayoutDictionaryDownloader;}(window));',start) +end+=len('exports.LayoutDictionaryDownloader=LayoutDictionaryDownloader;}(window));') +out=ROOT/'overrides/keyboard.gaiamobile.org/gaia_build_defer_settings.js' +out.write_text(bundle[:start]+source+'\n'+bundle[end:]) +print(out.relative_to(ROOT)) diff --git a/tools/refresh-settings-panels.py b/tools/refresh-settings-panels.py index cb854a6c..4a5465f7 100644 --- a/tools/refresh-settings-panels.py +++ b/tools/refresh-settings-panels.py @@ -12,6 +12,7 @@ modules = [ ('panels/wifi/wifi_network_list', 'js/panels/wifi/panel.js'), ('modules/page_transitions', 'js/main.js'), ('modules/settings_service', 'js/main.js'), + ('modules/panel_cache', 'js/main.js'), ('modules/dialog_service', 'js/modules/dialog_service.js'), ('modules/dialog_manager', 'js/modules/dialog_service.js'), ('modules/date_time', 'js/modules/date_time.js'), diff --git a/tools/test-ticket-settings.py b/tools/test-ticket-settings.py new file mode 100644 index 00000000..455bea32 --- /dev/null +++ b/tools/test-ticket-settings.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Regression checks for storage navigation and unavailable-feature contrast.""" +import importlib.util,json,time +from pathlib import Path +from marionette_client import Client +spec=importlib.util.spec_from_file_location('probe',Path(__file__).with_name('probe-desktop.py')) +p=importlib.util.module_from_spec(spec);spec.loader.exec_module(p) +with Client(2857) as c: + p.register(c) + def ev(code): + for _ in range(80): + reply=p.evaluate(c,'settings',code) + if not reply.get('missing'):break + time.sleep(.1) + if 'value' not in reply:raise RuntimeError(reply) + return reply['value'] + def wait(code): + for _ in range(80): + if ev(code):return + time.sleep(.1) + raise TimeoutError(code) + p.evaluate(c,'system','if(Service.query("locked"))await Service.request("unlock",{forcibly:true});await VulpesCompat.call("apps.launch",{manifestURL:"http://settings.localhost:8765/manifest.webapp"});return true;') + ev('location.reload();return true;');time.sleep(.6) + wait('return document.querySelector("section.current")?.id==="root" && require.defined("modules/settings_service");') + for panel in ['mediaStorage','root','applicationStorage','root','usbStorage','root']: + ev('await new Promise(resolve=>require(["modules/settings_service"],s=>s.navigate('+json.dumps(panel)+',{},resolve)));return true;') + wait('return document.querySelector("section.current")?.id==='+json.dumps(panel)+';') + errors=ev('return window.__vulpesDiagnostics;') + assert not errors,errors + # A failed dependency must not leave the navigation lock set. + ev('const cache=require("modules/panel_cache"),get=cache.get;cache.get=function(id,cb){cache.get=get;cb(null,new Error("test dependency failure"));};await new Promise(resolve=>require("modules/settings_service").navigate("mediaStorage",{},resolve));return true;') + time.sleep(.3) + ev('await new Promise(resolve=>require("modules/settings_service").navigate("applicationStorage",{},resolve));return true;') + wait('return document.querySelector("section.current")?.id==="applicationStorage";') + ratios={} + for theme in ['light','dark']: + ev('document.documentElement.dataset.vulpesTheme='+json.dumps(theme)+';VulpesGaiaUI.notice("This feature is not available on this platform yet.");return true;') + time.sleep(.2) + colors=ev('const p=document.querySelector("#vulpes-gaia-notice p");return [getComputedStyle(p).color,getComputedStyle(p).backgroundColor];') + def luminance(css): + rgb=[float(v)/255 for v in css.removeprefix('rgb(').removesuffix(')').split(',')] + linear=[v/12.92 if v<=.04045 else ((v+.055)/1.055)**2.4 for v in rgb] + return sum(a*b for a,b in zip(linear,[.2126,.7152,.0722])) + a,b=sorted(map(luminance,colors));ratio=(b+.05)/(a+.05);assert ratio>=4.5,(theme,colors,ratio) + ratios[theme]=round(ratio,2) + ev('document.querySelector("#vulpes-gaia-notice").requestSubmit();return true;') + ev('require("modules/settings_service").navigate("root");return true;') + print(json.dumps({'storagePanels':3,'returnNavigation':True,'failureRecovery':True,'contrast':ratios}))